Skip to content

role slider

Slider draft v0.1.0

Lets people pick a value in a range by moving a thumb along a track.

A slider sets a value whose position in a range matters more than its exact figure: a volume, a brightness, a price ceiling. A thumb slides along a track between a minimum and a maximum, by steps; the value can be shown next to the label. When the exact number matters, a Number field is better, alone or next to the slider.

Web implementation

Root .ds-slider on a <div>. Properties map to attributes: size → data-sizedisabled → data-disabled

packages/components/src/components/slider/slider.css
/*
 * Slider — vanilla CSS reference implementation.
 * Spec:   packages/components/specs/components/slider.spec.json
 * Tokens: packages/tokens/dist/css/components/slider.css (generated from packages/tokens/src/components/slider.tokens.json)
 *
 * Rules:
 *  - Only --slider-* custom properties may appear here. No raw values, no semantic tokens.
 *  - The root is a wrapper (label row, then the control); the native range input is the control,
 *    drawn by the component (appearance: none): its track is the input's own box, its thumb the
 *    vendor pseudo-elements (::-webkit-slider-thumb, ::-moz-range-thumb). Each vendor rule is
 *    written on its own: a selector list with an unknown pseudo-element is dropped as a whole.
 *  - Size is a data attribute on the root; states are native pseudo-classes of the input, and the
 *    root's [data-state="<state>"] forces them for review. Order: default < hover < active < disabled.
 */

.ds-slider {
  --_track-height: var(--slider-control-height-medium);
  --_thumb-size: var(--slider-thumb-size-medium);
  --_track-background: var(--slider-control-background-default);
  --_thumb-background: var(--slider-thumb-background-default);
  --_thumb-border-color: var(--slider-thumb-border-color-default);
  --_label-color: var(--slider-label-color-default);
  --_value-color: var(--slider-value-color-default);

  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: var(--slider-container-gap);
  min-inline-size: 0;
}

.ds-slider[data-size="small"] {
  --_track-height: var(--slider-control-height-small);
  --_thumb-size: var(--slider-thumb-size-small);
}

.ds-slider__header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  column-gap: var(--slider-container-column-gap);
}

.ds-slider__label {
  color: var(--_label-color);
  font-family: var(--slider-label-typography-font-family);
  font-size: var(--slider-label-typography-font-size);
  font-weight: var(--slider-label-typography-font-weight);
  line-height: var(--slider-label-typography-line-height);
  letter-spacing: var(--slider-label-typography-letter-spacing);
}

.ds-slider__value {
  color: var(--_value-color);
  font-family: var(--slider-value-typography-font-family);
  font-size: var(--slider-value-typography-font-size);
  font-weight: var(--slider-value-typography-font-weight);
  line-height: var(--slider-value-typography-line-height);
  letter-spacing: var(--slider-value-typography-letter-spacing);
  font-variant-numeric: tabular-nums;
}

/* ---- Control: the track is the input, the thumb its pseudo-element ------------ */

.ds-slider__control {
  box-sizing: border-box;
  display: block;
  inline-size: 100%;
  block-size: var(--_track-height);
  min-block-size: 0;
  margin-block: calc((var(--slider-control-min-height) - var(--_track-height)) / 2);
  margin-inline: 0;
  padding: 0;
  border: none;
  border-radius: var(--slider-control-border-radius);
  background-color: var(--_track-background);
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  transition: background-color var(--slider-control-transition-duration) var(--slider-control-transition-easing);
}

.ds-slider__control::-webkit-slider-runnable-track {
  block-size: var(--_track-height);
  border-radius: var(--slider-control-border-radius);
  background-color: var(--_track-background);
}
.ds-slider__control::-moz-range-track {
  block-size: var(--_track-height);
  border-radius: var(--slider-control-border-radius);
  background-color: var(--_track-background);
}

.ds-slider__control::-webkit-slider-thumb {
  box-sizing: border-box;
  inline-size: var(--_thumb-size);
  block-size: var(--_thumb-size);
  margin-block-start: calc((var(--_track-height) - var(--_thumb-size)) / 2);
  border: var(--slider-thumb-border-width) solid var(--_thumb-border-color);
  border-radius: var(--slider-thumb-border-radius);
  background-color: var(--_thumb-background);
  appearance: none;
  -webkit-appearance: none;
  transition: background-color var(--slider-control-transition-duration) var(--slider-control-transition-easing);
}
.ds-slider__control::-moz-range-thumb {
  box-sizing: border-box;
  inline-size: var(--_thumb-size);
  block-size: var(--_thumb-size);
  border: var(--slider-thumb-border-width) solid var(--_thumb-border-color);
  border-radius: var(--slider-thumb-border-radius);
  background-color: var(--_thumb-background);
  transition: background-color var(--slider-control-transition-duration) var(--slider-control-transition-easing);
}

/* ---- States -------------------------------------------------------------- */

.ds-slider:where([data-state="hover"]) .ds-slider__control:not(:disabled),
.ds-slider:not([data-state]) .ds-slider__control:hover:not(:disabled, :active) {
  --_thumb-background: var(--slider-thumb-background-hover);
}

.ds-slider:where([data-state="active"]) .ds-slider__control:not(:disabled),
.ds-slider__control:active:not(:disabled) {
  --_thumb-background: var(--slider-thumb-background-active);
}

.ds-slider:where([data-state="disabled"]),
.ds-slider:has(.ds-slider__control:disabled) {
  --_label-color: var(--slider-label-color-disabled);
  --_value-color: var(--slider-value-color-disabled);
}
.ds-slider:where([data-state="disabled"]) .ds-slider__control,
.ds-slider__control:disabled {
  --_track-background: var(--slider-control-background-disabled);
  --_thumb-background: var(--slider-thumb-background-disabled);
  --_thumb-border-color: var(--slider-thumb-border-color-disabled);
  cursor: not-allowed;
}

.ds-slider__control:focus {
  outline: none;
}

/* The ring is drawn around the whole track (the thumb cannot carry an outline in every browser). */
.ds-slider:where([data-state="focus-visible"]) .ds-slider__control,
.ds-slider__control:focus-visible {
  outline: var(--slider-control-outline-width-focus-visible) solid var(--slider-control-outline-color-focus-visible);
  outline-offset: var(--slider-control-outline-offset-focus-visible);
}

@media (prefers-reduced-motion: reduce) {
  .ds-slider__control,
  .ds-slider__control::-webkit-slider-thumb {
    transition: none;
  }
  .ds-slider__control::-moz-range-thumb {
    transition: none;
  }
}
packages/components/src/components/slider/slider.template.mjs
/**
 * Slider — HTML template for the web implementation.
 * Pure function of the spec properties (packages/components/specs/components/slider.spec.json) -> HTML string.
 * A native range input named by its label: the browser exposes the slider role, the value, its
 * bounds and the keyboard interaction (arrow keys, Home, End). The value, when shown, is an
 * output element bound to the input; without script it shows the initial value.
 *
 * Extra props: `id` names the input (unique in the page; without it instances are numbered in
 * render order); `name` is the form field's; `attrs` adds attributes to the input (aria-valuetext…).
 * Extra sandbox-only prop: `state` forces a visual state through data-state on the root.
 */
import { escapeHtml, escapeAttr } from '../../lib/template-utils.mjs';

export const meta = { id: 'slider', selector: '.ds-slider' };

let count = 0;

export function render(props = {}) {
  const { label = 'Value', value = 50, min = 0, max = 100, step = 1, showValue = false, size = 'medium', disabled = false, state = null, id = null, name = null, attrs: extra = {} } = props;
  const inputId = id ?? `ds-slider-${++count}`;
  const rootAttrs = ['class="ds-slider"', `data-size="${escapeAttr(size)}"`];
  if (disabled) rootAttrs.push('data-disabled');
  if (state && state !== 'default') rootAttrs.push(`data-state="${escapeAttr(state)}"`);
  const inputAttrs = ['type="range"', 'class="ds-slider__control"', 'data-part="control"', `id="${escapeAttr(inputId)}"`, `min="${escapeAttr(min)}"`, `max="${escapeAttr(max)}"`, `step="${escapeAttr(step)}"`, `value="${escapeAttr(value)}"`];
  if (name) inputAttrs.push(`name="${escapeAttr(name)}"`);
  if (disabled) inputAttrs.push('disabled');
  for (const [k, v] of Object.entries(extra)) {
    if (v === false || v == null) continue;
    inputAttrs.push(v === true ? escapeAttr(k) : `${escapeAttr(k)}="${escapeAttr(v)}"`);
  }
  const header = [`<label class="ds-slider__label" data-part="label" for="${escapeAttr(inputId)}">${escapeHtml(label)}</label>`];
  if (showValue) header.push(`<output class="ds-slider__value" data-part="value" for="${escapeAttr(inputId)}">${escapeHtml(value)}</output>`);
  return `<div ${rootAttrs.join(' ')}><div class="ds-slider__header">${header.join('')}</div><input ${inputAttrs.join(' ')}></div>`;
}

No other platform implementation recorded yet. Platform token outputs live under Platforms.