SliderATM
An input for selecting a value from a range. Built on native <input type="range"> with custom track and thumb styling, filled track indicator, and full keyboard support.
On this page (18)
§With label
§Steps with marks
A .slider-marks row after the slider draws a CSS-only tick under every step - labeled or bare (an empty child). It follows the slider's data-size, so the ticks stay on the thumb's travel.
§Colors
data-tone paints the fill and the thumb ring: success, warning, info, destructive - or any color through style='--slider-color: …'.
§Icons and emojis
A .slider-field row puts .slider-icon glyphs (an svg or an emoji) at either end and a .slider-value readout after the track.
§Emoji thumb
data-thumb-emoji draws the thumb as an emoji - one, or a space-separated list picked by the value (a mood scale).
§Units
data-unit (any Intl unit - celsius, percent, kilometer-per-hour, megabyte …) or data-currency formats the value into every output[for] and into aria-valuetext, so a screen reader says '21 °C', not '21'. A .slider-scale labels the ends.
§Range slider
Two .slider inputs in a .slider-range: both thumbs move, the span between them fills, the low value never passes the high one (data-min-gap keeps a distance). An output[for] naming both ids shows the range, formatted (Intl formatRange).
§States
Named states via the shared State API, driven per instance through the bound api:
default- enabled (authoreddisabledattribute restored; optional{ value }config presets the position)disabled- nativedisabledattribute: greyed track/thumb, no pointer or keyboard input
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/slider-{state}.png.
Machine contract - verified against slider.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
disabled | boolean | true, false | false | Native disabled state - interaction off, muted styling. |
value | number | — | 50 | Live slider value; set through the State API (setState('default', { value })) and read from input.value, so dragging updates it too. |
§API
Generated from slider.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type SliderState = 'default' | 'disabled' - setState(name, config) takes the config of the state it names.
| State | Description | ||||||
|---|---|---|---|---|---|---|---|
default | Enabled.
| ||||||
disabled | Disabled - not draggable, dimmed.
|
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends SliderState>(name: S, config?: SliderStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: SliderState; config: SliderStateConfigs[SliderState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: SliderState; config: SliderStateConfigs[SliderState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: SliderState; config: SliderStateConfigs[SliderState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.sliderApi.setState<S extends SliderState>(el: HTMLElement, name: S, config?: SliderStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.sliderApi.getState(el: HTMLElement): { name: SliderState; config: SliderStateConfigs[SliderState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.sliderApi.render(state: { name: SliderState; config: SliderStateConfigs[SliderState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.sliderApi.store(el: HTMLElement): Store<{ name: SliderState; config: SliderStateConfigs[SliderState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.sliderApi.commit<S extends SliderState>(el: HTMLElement, name: S, config?: SliderStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.sliderStates: SliderState[] | The declared states, 'default' first: default, disabled. |
§CSS view file
/* -- Slider component ------------------------------------------- */@layer components { /* Everything runs through a few private variables so a .slider-range wrapper (two thumbs) can hand its size and color down to both inputs: --_track / --_thumb (set by data-size, on the slider or on the range), --_c (set by data-tone) and the public --slider-color for any color. --_fill resolves them once, here, on the input. */ .slider { --slider-value: 50%; --_fill: var(--_c, var(--slider-color, var(--primary))); --_t: var(--_track, 0.5rem); --_d: var(--_thumb, 1.25rem); --_dir: to right; -webkit-appearance: none; appearance: none; width: 100%; /* the UA gives a range a 2px margin: with width 100% the track would overflow its box and drift off a .slider-marks scale */ margin-inline: 0; height: var(--_t); border-radius: 9999px; background: var(--secondary); cursor: pointer; outline: none; border: none; accent-color: var(--_fill); /* Track - WebKit (filled via gradient) */ &::-webkit-slider-runnable-track { height: var(--_t); border-radius: 9999px; background: linear-gradient( var(--_dir), var(--_fill) 0%, var(--_fill) var(--slider-value), var(--secondary) var(--slider-value), var(--secondary) 100% ); } /* Track - Firefox */ &::-moz-range-track { height: var(--_t); border-radius: 9999px; background: var(--secondary); border: none; } /* Filled portion - Firefox */ &::-moz-range-progress { height: var(--_t); border-radius: 9999px; background: var(--_fill); } /* Thumb - WebKit (re-centered on the track: (track - thumb) / 2). --slider-thumb-image paints a picture (an emoji, see data-thumb) */ &::-webkit-slider-thumb { -webkit-appearance: none; width: var(--_d); height: var(--_d); border-radius: 50%; background: var(--slider-thumb-image, none) center / 100% no-repeat, var(--background); border: 2px solid var(--_fill); margin-top: calc((var(--_t) - var(--_d)) / 2); cursor: pointer; transition: box-shadow 150ms, scale 150ms; } /* Thumb - Firefox */ &::-moz-range-thumb { width: var(--_d); height: var(--_d); border-radius: 50%; background: var(--slider-thumb-image, none) center / 100% no-repeat, var(--background); border: 2px solid var(--_fill); cursor: pointer; transition: box-shadow 150ms, scale 150ms; } /* Hover */ &:hover:not(:disabled) { &::-webkit-slider-thumb { box-shadow: 0 0 0 4px color-mix(in oklch, var(--_fill) 15%, transparent); } &::-moz-range-thumb { box-shadow: 0 0 0 4px color-mix(in oklch, var(--_fill) 15%, transparent); } } /* Focus */ &:focus-visible { &::-webkit-slider-thumb { outline: 2px solid var(--ring); outline-offset: 2px; } &::-moz-range-thumb { outline: 2px solid var(--ring); outline-offset: 2px; } } /* Disabled */ &:disabled { opacity: 0.5; cursor: not-allowed; &::-webkit-slider-thumb { cursor: not-allowed; } &::-moz-range-thumb { cursor: not-allowed; } } /* RTL: the range runs right to left, so does the fill */ &:dir(rtl) { --_dir: to left; } /* Vertical orientation */ &[data-orientation="vertical"] { --_dir: to top; writing-mode: vertical-lr; direction: rtl; width: var(--_t); height: 12rem; &::-webkit-slider-runnable-track { width: var(--_t); height: auto; } &::-webkit-slider-thumb { margin-top: 0; margin-left: calc((var(--_t) - var(--_d)) / 2); } } /* -- Emoji thumb: data-thumb="emoji" (slider.js draws the emoji from data-thumb-emoji into --slider-thumb-image) - no ring, no fill disc, a bit larger than the plain thumb. */ &[data-thumb="emoji"] { --_d: var(--_thumb, 2rem); &::-webkit-slider-thumb { border: 0; background-color: transparent; } &::-moz-range-thumb { border: 0; background-color: transparent; } &:active::-webkit-slider-thumb { scale: 1.15; } &:active::-moz-range-thumb { scale: 1.15; } } } /* -- Sizes: track thickness + thumb diameter (md == the default) ---- On a .slider or on a .slider-range (both inputs inherit). */ :is(.slider, .slider-range, .slider-marks) { &[data-size="xs"] { --_track: 0.25rem; --_thumb: 0.75rem; } &[data-size="sm"] { --_track: 0.375rem; --_thumb: 1rem; } &[data-size="md"] { --_track: 0.5rem; --_thumb: 1.25rem; } &[data-size="lg"] { --_track: 0.625rem; --_thumb: 1.5rem; } &[data-size="xl"] { --_track: 0.75rem; --_thumb: 1.75rem; } /* -- Tones: success / warning / info / destructive; any other color via style="--slider-color: …" */ &[data-tone="success"] { --_c: oklch(0.6 0.15 150); } &[data-tone="warning"] { --_c: oklch(0.78 0.16 75); } &[data-tone="info"] { --_c: oklch(0.6 0.16 250); } &[data-tone="destructive"] { --_c: var(--destructive); } } /* -- Marks: a CSS-only tick scale under a stepped slider ------------ <div class="slider-marks" aria-hidden="true"> right after the slider (or range); each child is a zero-width flex item, so space-between lands the tick centres on the thumb-centre travel (inset by half a thumb at each end). The tick is generated, the child's text is the optional label. It follows the slider's data-size when it is the slider's next sibling (or set data-size on the marks). */ .slider-marks { display: flex; justify-content: space-between; padding-inline: calc(var(--_thumb, 1.25rem) / 2); margin-top: 0.375rem; font-size: 0.6875rem; line-height: 1; font-variant-numeric: tabular-nums; color: var(--muted-foreground); & > * { /* zero-width: flex-basis 0 alone leaves min-width:auto, which would widen the item to its label and push the tick off its step */ flex: 0 0 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 0.25rem; white-space: nowrap; } & > *::before { content: ""; width: 1px; height: 0.375rem; background: var(--border); } /* a tick without a label (an empty child) stays a short tick */ & > :empty::before { height: 0.25rem; } } :is(.slider, .slider-range)[data-size="xs"] + .slider-marks:not([data-size]) { --_thumb: 0.75rem; } :is(.slider, .slider-range)[data-size="sm"] + .slider-marks:not([data-size]) { --_thumb: 1rem; } :is(.slider, .slider-range)[data-size="lg"] + .slider-marks:not([data-size]) { --_thumb: 1.5rem; } :is(.slider, .slider-range)[data-size="xl"] + .slider-marks:not([data-size]) { --_thumb: 1.75rem; } /* -- Range: two thumbs on one track --------------------------------- Two .slider inputs stacked in a .slider-range; only their thumbs take the pointer, the wrapper paints one track with the span between the thumbs filled (slider.js keeps --range-from / --range-to in sync and the low value <= the high one). Each input keeps its own keyboard, label and State API. */ .slider-range { --range-from: 25%; --range-to: 75%; --_fill: var(--_c, var(--slider-color, var(--primary))); --_dir: to right; position: relative; display: block; width: 100%; height: var(--_thumb, 1.25rem); &::before { content: ''; position: absolute; inset-inline: 0; top: 50%; height: var(--_track, 0.5rem); translate: 0 -50%; border-radius: 9999px; background: linear-gradient( var(--_dir), var(--secondary) var(--range-from), var(--_fill) var(--range-from), var(--_fill) var(--range-to), var(--secondary) var(--range-to) ); } &:dir(rtl) { --_dir: to left; } & > .slider { position: absolute; inset-inline: 0; top: 50%; translate: 0 -50%; margin: 0; background: none; pointer-events: none; &::-webkit-slider-runnable-track { background: none; } &::-moz-range-track { background: none; } &::-moz-range-progress { background: none; } &::-webkit-slider-thumb { pointer-events: auto; } &::-moz-range-thumb { pointer-events: auto; } } /* the thumb last moved stays on top (both can reach the same value) */ & > .slider[data-active] { z-index: 1; } &:has(.slider:disabled) { opacity: 0.5; } } /* -- Field: icons, emojis and values around a slider ----------------- */ .slider-field { display: flex; align-items: center; gap: 0.75rem; width: 100%; & > :is(.slider, .slider-range) { flex: 1 1 auto; min-width: 0; } } .slider-icon { display: inline-grid; place-items: center; flex-shrink: 0; width: 1.25rem; height: 1.25rem; font-size: 1.125rem; line-height: 1; color: var(--muted-foreground); & svg { width: 1.125rem; height: 1.25rem; } } .slider-value { flex-shrink: 0; min-width: 3.5em; text-align: end; font-size: 0.875rem; font-variant-numeric: tabular-nums; color: var(--foreground); } /* a scale under the track: the first and last labels align to the ends */ .slider-scale { display: flex; justify-content: space-between; margin-top: 0.375rem; font-size: 0.75rem; font-variant-numeric: tabular-nums; color: var(--muted-foreground); } /* Reduced motion */ @media (prefers-reduced-motion: reduce) { .slider { &::-webkit-slider-thumb { transition: none; } &::-moz-range-thumb { transition: none; } } } /* High contrast */ @media (prefers-contrast: more) { .slider-marks { color: var(--foreground); & > *::before { background: var(--foreground); } } .slider { &::-webkit-slider-thumb { border-width: 3px; } &::-moz-range-thumb { border-width: 3px; } } } /* Forced colors (Windows High Contrast Mode) */ @media (forced-colors: active) { .slider-marks { color: CanvasText; & > *::before { background: CanvasText; } } .slider { &::-webkit-slider-runnable-track { background: ButtonFace; border: 1px solid ButtonText; } &::-moz-range-track { background: ButtonFace; border: 1px solid ButtonText; } &::-moz-range-progress { background: Highlight; } &::-webkit-slider-thumb { background: ButtonText; border-color: ButtonText; } &::-moz-range-thumb { background: ButtonText; border-color: ButtonText; } &:focus-visible { &::-webkit-slider-thumb { outline-color: Highlight; } &::-moz-range-thumb { outline-color: Highlight; } } } .slider-range::before { background: Highlight; } }}§JS view file
/* -- Slider component ------------------------------------------- */// Fill-track painting for native range inputs, two-thumb ranges, Intl-// formatted values (data-unit / data-currency → <output> + aria-valuetext),// emoji thumbs, plus the named-state API so// agents/tests can enable/disable (and preset) a slider by name (AGENTS.md// "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent, textLocale } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const sliderStates = ['default', 'disabled'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state (getState() reports the value). */export interface SliderStateConfigs { /** Enabled. */ default: { /** the value to set (the range input's value); getState() reports it */ value?: number | string; }; /** Disabled - not draggable, dimmed. */ disabled: { /** the value to set; getState() reports it */ value?: number | string; };}/** 0..100 - where the value sits between min and max. */function percentOf(el) { const min = parseFloat(el.min || 0); const max = parseFloat(el.max || 100); return max === min ? 0 : ((parseFloat(el.value) - min) / (max - min)) * 100;}/** Intl number format from data-currency / data-unit (on the slider or its * .slider-range), fraction digits from the step. */function formatterOf(el) { const host = el.closest('.slider-range') ?? el; const d = { ...host.dataset, ...el.dataset }; const step = el.step && el.step !== 'any' ? el.step : '1'; const digits = step.includes('.') ? step.split('.')[1].length : 0; const opts = { maximumFractionDigits: digits, minimumFractionDigits: 0 }; if (d.currency) Object.assign(opts, { style: 'currency', currency: d.currency }); else if (d.unit) Object.assign(opts, { style: 'unit', unit: d.unit, unitDisplay: d.unitDisplay || 'short' }); const lang = textLocale(el); try { return new Intl.NumberFormat(lang, opts); } catch { return new Intl.NumberFormat(lang, { maximumFractionDigits: digits }); }}const hasFormat = (el) => { const host = el.closest('.slider-range') ?? el; return !!(el.dataset.unit || el.dataset.currency || host.dataset.unit || host.dataset.currency);};/** <output for="id …"> elements that show this slider (a range pair: both ids). */function outputsOf(el) { if (!el.id) return []; return [...dfDollar<HTMLOutputElement>('output[for]').toArray()].filter((o) => o.htmlFor.contains(el.id));}/** An emoji as an image (data-thumb-emoji: one, or a space-separated list * picked by the value - "😞 😐 🙂 😄"). */function emojiThumb(el) { const list = (el.dataset.thumbEmoji || '').trim().split(/\s+/).filter(Boolean); if (!list.length) return; const i = Math.min(list.length - 1, Math.floor((percentOf(el) / 100) * list.length)); const emoji = list[i]; if (el._emoji === emoji) return; el._emoji = emoji; const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><text x="16" y="17" font-size="26" text-anchor="middle" dominant-baseline="central">${emoji}</text></svg>`; el.style.setProperty('--slider-thumb-image', `url("data:image/svg+xml,${encodeURIComponent(svg)}")`);}function updateSliderValue(el) { el.style.setProperty('--slider-value', `${percentOf(el)}%`); if (el.dataset.thumbEmoji) emojiThumb(el); const range = el.closest('.slider-range'); if (range) paintRange(range); const fmt = hasFormat(el) ? formatterOf(el) : null; // a spoken value with its unit ("20 °C", "€250") instead of a bare number if (fmt) el.setAttribute('aria-valuetext', fmt.format(parseFloat(el.value))); for (const out of outputsOf(el)) { const pair = range ? rangeInputs(range) : null; const f = fmt ?? formatterOf(el); if (pair && out.htmlFor.contains(pair[0].id) && out.htmlFor.contains(pair[1].id)) { const a = parseFloat(pair[0].value); const b = parseFloat(pair[1].value); out.value = a === b ? f.format(a) : f.formatRange(a, b); } else { out.value = f.format(parseFloat(el.value)); } }}/* -- Range: two sliders, low <= high ------------------------------- */const rangeInputs = (range) => [...dfDollar(range).find<HTMLInputElement>(':scope > .slider').toArray()].slice(0, 2);function paintRange(range) { const [lo, hi] = rangeInputs(range); if (!lo || !hi) return; range.style.setProperty('--range-from', `${percentOf(lo)}%`); range.style.setProperty('--range-to', `${percentOf(hi)}%`);}function initRange(range) { const [lo, hi] = rangeInputs(range); if (!lo || !hi) return; const gap = parseFloat(range.dataset.minGap || '0'); const clamp = (moved) => { const a = parseFloat(lo.value); const b = parseFloat(hi.value); if (b - a < gap || a > b) { if (moved === lo) lo.value = String(b - gap); else hi.value = String(a + gap); } lo.toggleAttribute('data-active', moved === lo); hi.toggleAttribute('data-active', moved === hi); updateSliderValue(lo); updateSliderValue(hi); }; lo.addEventListener('input', () => clamp(lo)); hi.addEventListener('input', () => clamp(hi)); for (const s of [lo, hi]) s.addEventListener('pointerdown', () => { lo.toggleAttribute('data-active', s === lo); hi.toggleAttribute('data-active', s === hi); }); paintRange(range);}/** * The markup of a state, for render(): the attributes a state writes, applied * to a detached copy of the authored markup ('default' IS the authored * markup). The live element gets the same markup from triggerStateChange - * the e2e render round trip proves they agree. */function applyMarkup(el, stateName) { if (stateName === 'disabled') dfDollar(el).attr('disabled', '');}/** * UI side of setState: 'default' restores the authored enabled state and * optionally presets { value }; 'disabled' uses the native disabled * attribute (CSS :disabled styles it, keyboard/pointer go inert for free). */function triggerStateChange(el, stateName, config) { switch (stateName) { case 'default': el.disabled = el._defaultDisabled ?? false; if (config?.value !== undefined) el.value = String(config.value); updateSliderValue(el); break; case 'disabled': el.disabled = true; break; }}/** Registry-level API; pass the input element explicitly. Unknown names throw. */export const sliderApi = componentState<HTMLInputElement>({ component: 'slider', states: sliderStates, apply: (el, state) => triggerStateChange(el, state.name, state.config), read: (el, state) => { // reflect reality: dragging/disabling changes the UI without setState() return { name: el.disabled ? 'disabled' : 'default', config: { ...state.config, value: el.value }, }; }, markup: (el, state) => applyMarkup(el, state.name),});df$.sliderApi = sliderApi;df$.sliderStates = sliderStates;function init() { dfDollar('.slider-range:not([data-init])').toArray().forEach((range) => { range.dataset.init = ''; initRange(range); }); dfDollar<HTMLInputElement>('.slider:not([data-init])').toArray().forEach((el) => { el.dataset.init = ''; // remember the authored disabled state so setState('default') restores it el._defaultDisabled = el.disabled; // el.store + el.api (AGENTS.md "State through stores") bindComponent(el, sliderApi); if (el.dataset.thumbEmoji && !el.dataset.thumb) el.dataset.thumb = 'emoji'; updateSliderValue(el); el.addEventListener('input', () => updateSliderValue(el)); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub