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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
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.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- TicksMeaningful stops only
The minimum, the maximum and any position that has a name. Labelling every step turns the control into a ruler.
- 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.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| hit area | Invisible target height around the track |
Radius
| Token | Value | Used for |
|---|---|---|
| full | — | Track and thumb |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e2 | — | Thumb, so it sits above the track |
Typography
| Token | Value | Used for |
|---|---|---|
| tabular-nums | — | Readout and tick labels |
Motion
| Token | Value | Used for |
|---|---|---|
| --duration-fast | Thumb hover and focus — never the position |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Icon | Label gap | Type | Min width | Max width | Touch target | When to use |
|---|---|---|---|---|---|---|---|---|
| Small | 4px track | 16px thumb | — | — | — | — | 44px hit area | Inline beside an icon — volume, zoom, opacity. |
| Medium | 6px track | 20px thumb | — | — | — | — | 44px hit area | The default. A labelled form field. |
| Track length | — | — | — | — | 10rem | 24rem | — | Below 10rem precision collapses; above 24rem the thumb travels further than the eye wants to follow. |
| Ticks | — | — | Meaningful stops only | 12px | — | — | — | Minimum, maximum, and any position with a name. |
| Paired field | — | — | — | — | 5rem | — | — | A Number Input beside the track for values that must be exact. |
<input type="range" class="sr-overlay" />
+ styled track and thumb behind itaria-valuetext="70 per cent"Not a checklist to run at the end. These are the requirements the component was built from.
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 Down | Moves by a larger increment, conventionally ten steps. |
| Home / End | Jumps to the minimum or maximum. |
| Tab | Reaches the slider. A range slider has two thumbs and therefore two stops. |
| Shift + arrows | Optional 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="slider" | The control | Implicit on input[type=range], which is the reason to build on it. |
| aria-valuenow / valuemin / valuemax | The control | Native and automatic. A div-based slider has to maintain all three by hand, and they drift. |
| aria-valuetext | The control | The value with its units: "70 per cent", "30 days". Without it the number is announced with no meaning attached. |
| aria-label | The control | Or a real label element. An unlabelled slider announces as "slider, 70" with no indication of what it controls. |
| aria-orientation | A vertical slider | Changes which arrow keys are announced as increasing. |
Example usage
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
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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 / max | number | 0 / 100 | The visible extremes. If the span exceeds about two orders of magnitude, this is the wrong control. |
| step | number | 1 | The 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". |
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.