Skip to content

Slider

A value on a continuum, for when the approximate position matters more than the exact figure. If the number matters, pair it with a field.

Also called Range, Scrubber, Track — in this system all of them are Slider.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground
Sample rate70%

Slider plus number field

The combination that solves both problems: drag for the rough position, type for the exact one. Both edit the same state, and neither is authoritative.

%

Ticks name the meaningful stops

Labels on the positions that mean something turn an anonymous track into a scale. Do not label every step — that is a ruler, not a control.

With an icon and an immediate effect

Volume and zoom are the archetypes: the effect is instantly perceivable, so the number is irrelevant and the position is the whole interface.

Where a slider stops working

A 200px track over a 0–10,000 range gives each pixel fifty units. No amount of care makes that precise, and the user cannot tell they missed.

0–100, step 5
0–10,000, step 1

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Default
Minimum
Maximum
Small
Disabled
Stepped
With ticks
70%
Readout

Anatomy

Every part, every measurement, and the reason it is that number.

A track, a filled portion showing progress from the minimum, a thumb, and tick labels on the positions that mean something.

  1. Track6px (4px small)

    Thin enough to read as a scale rather than a progress bar, thick enough to be a visible target on its own.

  2. Filled portionAccent, from the minimum

    Shows how far along the range the value sits. It is the difference between "a dot on a line" and "60 out of 100" at a glance.

  3. Thumb20px (16px small)

    A white disc with an accent ring, in both themes. It has to read as a physical object sitting on the track — that metaphor is the whole affordance.

  4. Hit area44px tall, invisible

    The interactive area is far taller than the 6px track. Without it, grabbing the thumb is a test of accuracy rather than an interaction.

  5. Focus ring3px halo on the thumb

    On the thumb, not the track. The thumb is what moves, so it is what has to be visibly focused.

  6. TicksMeaningful stops only

    The minimum, the maximum and any position that has a name. Labelling every step turns the control into a ruler.

  7. ReadoutAbove right, tabular

    Tabular figures so the number does not shift as it changes. Above the track, where it does not compete with the tick labels.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-layer-active—Unfilled track
--ds-accent—Filled track and thumb ring
--ds-accent-subtle—Focus halo on the thumb
--ds-border-strong—Disabled thumb ring and fill
--ds-fg—The value readout and the active tick
--ds-fg-muted—Inactive tick labels

Spacing

TokenValueUsed for
hit areaInvisible target height around the track

Radius

TokenValueUsed for
full—Track and thumb

Shadow

TokenValueUsed for
--shadow-e2—Thumb, so it sits above the track

Typography

TokenValueUsed for
tabular-nums—Readout and tick labels

Motion

TokenValueUsed for
--duration-fastThumb hover and focus — never the position

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightIconLabel gapTypeMin widthMax widthTouch targetWhen to use
Small4px track16px thumb————44px hit areaInline beside an icon — volume, zoom, opacity.
Medium6px track20px thumb————44px hit areaThe default. A labelled form field.
Track length————10rem24rem—Below 10rem precision collapses; above 24rem the thumb travels further than the eye wants to follow.
Ticks——Meaningful stops only12px———Minimum, maximum, and any position with a name.
Paired field————5rem——A Number Input beside the track for values that must be exact.

Do

<input type="range" class="sr-overlay" />
+ styled track and thumb behind it
Build on input[type=range]It brings the full keyboard model, the value semantics, forced-colors support and platform gestures for free. A div-based slider reimplements all of that and gets some of it wrong.
%
Pair it with a field when the number mattersDragging to exactly 47 on a 200px track is genuinely hard. The pair gives the user the rough gesture and the exact value without choosing between them.
aria-valuetext="70 per cent"
Give the value units in aria-valuetext"70" read aloud is ambiguous. "70 per cent" is the value. The native aria-valuenow carries the number; valuetext carries the meaning.
Make the hit area far taller than the trackA 6px target is a test of accuracy. A 44px invisible band around it makes the control feel forgiving without changing how it looks.

Don't

Do not use one for a value that must be exactThe user cannot tell they landed on 4,237 instead of 4,200, and there is nothing on screen that would let them check.
transition: inset-inline-start 200ms → drag feels broken
Do not animate the thumb positionThe thumb must sit under the pointer. Any transition on its position makes dragging feel like the control is lagging behind the finger.
Do not hide the value entirelyWithout a readout or a labelled effect, the user is guessing. A slider with no feedback is a control that cannot be used deliberately.
Do not label every stepTwenty tick labels on a 240px track overlap into noise and make the control look like measuring equipment rather than something to adjust.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.11Non-text ContrastAA2.1.1KeyboardA2.5.7Dragging MovementsAA2.5.8Target Size (Minimum)AA4.1.2Name, Role, ValueA

Contrast

  • The filled track must reach 3:1 against the unfilled track — the boundary between them is the value.
  • The thumb must reach 3:1 against both the track and the page background, which is why it is a white disc with a coloured ring rather than a solid fill.
  • Tick labels are content and owe 4.5:1.
  • A disabled slider still has to show its value — grey it down, but do not make the position unreadable.

Keyboard

← / ↓Decreases by one step. → / ↑ increases.
Page Up / Page DownMoves by a larger increment, conventionally ten steps.
Home / EndJumps to the minimum or maximum.
TabReaches the slider. A range slider has two thumbs and therefore two stops.
Shift + arrowsOptional fine adjustment on a coarse step. Worth adding when the step is large.

Screen readers

  • Announce as "Sample rate, slider, 70 per cent". The label and the valuetext together are the whole announcement.
  • Do not announce every intermediate value during a drag. The native control throttles this correctly; a custom one usually does not.
  • For a two-thumb range, name each end: "Minimum price" and "Maximum price", not "slider" twice.

Focus & touch

  • The focus ring is on the thumb, because the thumb is what moves. It must be visible at both ends of the track, which means the halo cannot be clipped by the container — the slider needs vertical padding of its own.
  • WCAG 2.5.7 requires a non-dragging alternative, which is why a slider that is the only way to set a value needs a paired field or stepper buttons. The hit area must be at least 44px tall, and the thumb should grow on press so it is not hidden under the finger. Never place a horizontal slider where a horizontal swipe is also a navigation gesture — the two fight, and the slider loses.
AttributeApplied toNotes
role="slider"The controlImplicit on input[type=range], which is the reason to build on it.
aria-valuenow / valuemin / valuemaxThe controlNative and automatic. A div-based slider has to maintain all three by hand, and they drift.
aria-valuetextThe controlThe value with its units: "70 per cent", "30 days". Without it the number is announced with no meaning attached.
aria-labelThe controlOr a real label element. An unlabelled slider announces as "slider, 70" with no indication of what it controls.
aria-orientationA vertical sliderChanges which arrow keys are announced as increasing.

Code

Example usage

tsx
1import { Slider } from '@/ui/Input'23<Field label="Sample rate">4  <Slider5    min={0}6    max={100}7    step={5}                        // match how people talk about the value8    value={rate}9    onChange={setRate}10    format={(v) => `${v} per cent`}  // becomes aria-valuetext11  />12</Field>1314// Slider + field: the pair that solves both problems at once. Both edit the15// same state; neither is authoritative.16<Row>17  <Slider min={0} max={100} value={value} onChange={setValue} label="Opacity" />18  <NumberInput19    value={value}20    onValueChange={(v) => setValue(clamp(Number(v) || 0, 0, 100))}21    min={0}22    max={100}23    suffix="%"24  />25</Row>2627// Style by overlaying on a transparent native input. The native control keeps28// the keyboard model, the value semantics and forced-colors support.29<div className="relative">30  <input type="range" className="absolute inset-0 z-10 w-full opacity-0" />31  <span className="track" />32  <span className="fill"  style={{ inlineSize: `${pct}%` }} />33  <span className="thumb" style={{ insetInlineStart: `calc(${pct}% - 10px)` }} />34</div>

Framework-free HTML

html
<div class="ds-field">
  <div class="ds-slider__head">
    <label for="rate">Sample rate</label>
    <output for="rate">70%</output>
  </div>

  <div class="ds-slider">
    <input
      id="rate"
      type="range"
      min="0"
      max="100"
      step="5"
      value="70"
      aria-valuetext="70 per cent"
    />
    <span class="ds-slider__track" aria-hidden="true"></span>
    <span class="ds-slider__fill"  aria-hidden="true" style="inline-size: 70%"></span>
    <span class="ds-slider__thumb" aria-hidden="true" style="inset-inline-start: 70%"></span>
  </div>

  <div class="ds-slider__ticks" aria-hidden="true">
    <span>0</span><span>50</span><span>100</span>
  </div>
</div>

CSS

css
.ds-slider {
  position: relative;
  display: flex;
  align-items: center;
  /* The track is 6px; the TARGET is 44px. Without this, grabbing the thumb
     is a test of accuracy. */
  block-size: 44px;
}

.ds-slider input[type='range'] {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  opacity: 0;                        /* keeps every native behaviour */
  cursor: pointer;
  margin: 0;
}

.ds-slider__track,
.ds-slider__fill {
  position: absolute;
  block-size: 6px;
  border-radius: 999px;
}
.ds-slider__track { inset-inline: 0; background: var(--ds-layer-active); }
.ds-slider__fill  { inset-inline-start: 0; background: var(--ds-accent); }

/* White disc with a coloured ring: 3:1 against both the track and the page,
   in either theme. */
.ds-slider__thumb {
  position: absolute;
  inline-size: 20px;
  block-size: 20px;
  translate: -50% 0;
  border: 2px solid var(--ds-accent);
  border-radius: 999px;
  background: #fff;
  box-shadow: var(--shadow-e2);
}

/* On the thumb, because the thumb is what moves. */
input[type='range']:focus-visible ~ .ds-slider__thumb {
  box-shadow: 0 0 0 3px var(--ds-accent-subtle);
}

/* Never transition the position: the thumb must sit under the pointer. */
.ds-slider__thumb { transition: box-shadow 120ms, scale 120ms; }

@media (pointer: coarse) {
  /* Grow on press so the thumb is not hidden under the finger. */
  input[type='range']:active ~ .ds-slider__thumb { scale: 1.2; }
}

Component API

Slider

PropTypeDefaultDescription
value*number—Controlled. Clamp to the range before passing it in.
onChange*(v: number) => void—Fires continuously during a drag. Debounce anything expensive downstream, not the value itself.
min / maxnumber0 / 100The visible extremes. If the span exceeds about two orders of magnitude, this is the wrong control.
stepnumber1The design decision. Match it to how people talk about the value.
format(v: number) => string—Produces the readout and aria-valuetext. Always include the units.
ticks{ value: number; label: string }[]—Meaningful stops only. Never one per step.
label*string—Accessible name. An unlabelled slider announces as "slider, 70".

Notes

Professional tips

  • Show the effect live wherever you can. A brightness slider that only updates on release is a slider the user has to guess at.
  • Snap to meaningful values with the step rather than letting people land on 47.3. Round numbers are what users mean and what they will report back to you.
  • For a two-thumb range, stop the thumbs crossing and never let them fully overlap — an invisible thumb is an unusable one.
  • Put the readout above the track, not below. Tick labels live below, and stacking both there makes the control taller than it needs to be.
  • Double-click or a Reset control to return to the default is worth adding on any slider people will experiment with.

Performance

  • Throttle expensive side effects to animation frames. A slider driving a canvas re-render fires far more often than the screen refreshes.
  • Update the thumb with transform rather than an inset property, so dragging does not force layout on every pointer event.
  • Keep the drag value in local state and lift it on release when the parent tree is large — a re-render of a whole form on every pointermove is visible.
  • Never transition the thumb position. It costs nothing to compute and makes the control feel broken.

Common mistakes

  • Rebuilding the slider from divs, losing the keyboard model, the value semantics and forced-colors support.
  • A 6px hit area, making the thumb a test of accuracy.
  • No aria-valuetext, so "70" is announced with no units or meaning.
  • Animating the thumb position, which makes dragging feel laggy.
  • A range so wide that each pixel is fifty units, with nothing on screen to reveal the imprecision.
  • A tick label per step, turning the control into a ruler.
  • No non-dragging alternative, which fails WCAG 2.5.7 for anyone who cannot drag.

Real-world recommendations

  • Volume, brightness and zoom are the cases where a slider is unambiguously right: the effect is immediate and perceivable, so the number never matters.
  • Price filters almost always need a paired field. Users have an exact budget in mind and cannot drag to it.
  • On touch, a slider inside a horizontally scrolling area is a permanent conflict. Move it out of the scroll region or make it vertical.
  • If your analytics show users overwhelmingly landing on the default, the slider is decoration — ship the default and let them change it somewhere else.