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 (17)
§Default
Range slider with default value.
§With label
§With value display
Shows current value using a native <output> element.
§With steps
Slider with discrete step increments.
§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.
§Vertical
Vertical orientation using data-orientation='vertical'.
§Disabled
Slider in disabled state.
§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).
§Right to left
In dir='rtl' the range runs right to left and so do the fill and the two-thumb span.
§Sizes
{'Track and thumb scale together via '}data-size{' on the '}{''}{' - the default equals '}md.
§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. |
§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 } from '../../shared/state-api.js';const df$ = defussGlobals();const sliderStates = ['default', 'disabled'];/** 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 = el.closest('[lang]')?.lang || undefined; 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 [...document.querySelectorAll('output[for]')].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) => [...range.querySelectorAll(':scope > .slider')].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);}/** * 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 = { setState(el, stateName, config = {}) { if (!sliderStates.includes(stateName)) { throw new Error(`slider: unknown state "${stateName}" (supported: ${sliderStates.join(', ')})`); } triggerStateChange(el, stateName, config); // state lives on the ELEMENT, not the module (many sliders per page) el.dataset.stateName = stateName; el._stateConfig = config; }, getState(el) { // reflect reality: dragging/disabling changes the UI without setState() return { name: el.disabled ? 'disabled' : 'default', config: { ...el._stateConfig, value: el.value }, }; },};df$.sliderApi = sliderApi;df$.sliderStates = sliderStates;function init() { document.querySelectorAll('.slider-range:not([data-init])').forEach((range) => { range.dataset.init = ''; initRange(range); }); document.querySelectorAll('.slider:not([data-init])').forEach((el) => { el.dataset.init = ''; // remember the authored disabled state so setState('default') restores it el._defaultDisabled = el.disabled; // bind-scope the api per instance: `$('#volume').api.setState('disabled')` el.api = { setState: (stateName, config) => sliderApi.setState(el, stateName, config), getState: () => sliderApi.getState(el), }; 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