Theme
On this page (17)
Component Skill — components/slider/component-skill.md

Native basis

<input type="range"> - native range input with built-in keyboard, touch, and assistive technology support.

Web Platform APIs

<input type="range">::-webkit-slider-thumb::-webkit-slider-runnable-track::-moz-range-progress<output>color-mix()prefers-reduced-motionforced-colors

Classes

.slider.slider-range.slider-field.slider-icon.slider-value.slider-scale.slider-marks

Data attributes

data-size (xs … xl), data-tone (success, warning, info, destructive), data-orientation="vertical", data-unit / data-currency, data-thumb-emoji, data-min-gap (on .slider-range).

Keyboard

• Right Arrow / Up Arrow - increase value by one step

• Left Arrow / Down Arrow - decrease value by one step

• Home - set to minimum value

• End - set to maximum value

• Page Up / Page Down - increase/decrease by larger step

Accessibility

• Native <input type="range"> provides implicit role="slider", aria-valuenow, aria-valuemin, aria-valuemax.

• Use <label for> to associate a visible label.

• Use aria-valuetext for human-readable value descriptions (e.g., "50%", "Medium").

• Use aria-label when no visible <label> is present.

• Use <output for> to display the current value accessibly.

Notes

• Firefox uses ::-moz-range-progress for the filled track. WebKit/Blink uses a linear-gradient on the track with --slider-value set by JS.

• Vertical sliders use writing-mode: vertical-lr; direction: rtl for cross-browser support.

• accent-color set as fallback for browsers that don't support custom pseudo-element styling.

§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 (authored disabled attribute restored; optional { value } config presets the position)
  • disabled - native disabled attribute: 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:

StateTypeValuesDefaultDescription
disabledbooleantrue, falsefalseNative disabled state - interaction off, muted styling.
valuenumber—50Live 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