Slider
APG's slider and its multi-thumb sibling: a number in is a number out, the thumbs are placed by logical properties, and the only thing that is not a shared class is where they are.
Import
JSX
import Slider from '@box-kite/react/components/slider';Usage
40JSX
<Slider label="Volume" defaultValue={40} onValueChange={(value) => setVolume(value)} />A number in is a number out
The number of thumbs is the value's own shape, so there is no second prop saying which kind of slider this is.
defaultValue={40} is one thumb whose onValueChange hands back a number; defaultValue={[20, 80]} is a range whose handler takes a number[]. TypeScript infers which from the value you wrote, so neither one needs narrowing at the call site.20 – 80JSX
<Slider label="Price" defaultValue={[20, 80]} thumbLabels={['Lowest', 'Highest']} format={(value) => `${value} lei`} />A thumb may meet the one beside it and never pass it: crossing would renumber the thumbs under the focus that is on one of them. Three thumbs and more work the same way — each is held between its neighbours, and the fill spans the outermost two.
Why this is not an input
Every other control in this library is a real form element with a role over it — a
Switch is a checkbox, a RadioGroup is a set of radios. A slider is the one place the platform loses. <input type="range"> cannot hold two thumbs, and its track and thumb are vendor pseudo-elements — ::-webkit-slider-thumb, ::-moz-range-track — that no typed prop can reach. Styling one is a rewrite of every part, at which point the native element is only supplying the keyboard.The form still works
name renders a hidden input per thumb, so a range posts two values under that name and a plain FormData reads them. That is the half of the native element worth keeping.And the keyboard is APG's, not the browser's
Both arrow pairs on both orientations,
PageUp/PageDown for largeStep, and Home/End on the ends themselves — on the step grid or off it, the way a range input reaches its own maximum.The position is an inline style, deliberately
Everything a slider paints is a shared class except where its thumbs are. That one value is per frame of a drag, and a class for it would be a rule per frame — written into the stylesheet and never freed. So it is an inline
inset-inline-start, which is the exception useAnchorPosition's anchor name and the travelling tab indicator already take.A ring can round its fraction into a class; a slider cannot
ProgressRing rounds to half a percent and pays nothing, because nobody drags a ring. Round a thumb that far and a wide track visibly stair-steps under the pointer.Everything else is still a class
The track, the fill's colour, the thumb, the focus ring, the forced-colors fallback and both themes are shared rules — one set for every slider on the page, however many there are.
A press travels, and a nudge does not
A press on the track is the one move the eye has to follow, so the thumb animates the whole way. Every other move — a drag, an arrow key, a held arrow — takes a short 60ms linear travel on both the thumb and the fill instead, so they keep up and stay locked together.
Short rather than off, because off steps
A value on a grid can only ever be at its grid positions, so a slider of 1 in 100 moves
3.2px at a time on a 320px track — exact, and visibly steppy. 60ms interpolates between the steps, for 4.4px of average lag.Eased out, which is neither linear nor eased
A repeat restarts the transition every 33ms, so only its first half is ever seen — and on
ease-out that half is the straight part, so the thumb glides while the key is held and still decelerates on the last transition, the one allowed to finish. Plain ease replays its slow-in on every repeat and pulses (a 2.7× swing in speed inside each step, against 1.6×). linear is the smoothest of the three while held (1.15×) and loses on the other two counts: it rides 1.4 steps behind the value where ease-out rides 0.9, and it arrives at full speed where ease-out lands at 0.6 of cruising. A 33ms wobble is below what the eye resolves as a speed change; a stale thumb and a dead stop are not.Both parts, or neither
Every Box carries a 250ms
all transition, so a fill left with the default eased towards a thumb that had already arrived — up to 59px behind it mid-drag, and still moving for ~190ms after the pointer stopped.An arrow is a nudge, not a jump
One tap used to spend the full 250ms travel crossing
3.2px, which is the lag it read as — and holding the key began with it, before the repeats arrived. Every arrow is short now, held or not: a tap arrives in 60ms.The real dial is
step: it is what a slider can be smoother than, and step={0} is continuous, so the thumb follows the pointer with nothing to interpolate. The smoothing is one variant, tracking, on slider.fill and slider.thumb — a style tree of your own can lengthen it or take it off.It mirrors for free
The fill and the thumbs are placed with
inset-inline-start and centred with a logical margin, so a right-to-left page draws the minimum on the right with nothing declared twice and no re-render. The half that is not free is the keyboard: the sideways arrows swap, because ArrowLeft is the increase when the maximum is on the left, and ArrowUp never swaps, because the block axis has no reading order.The direction is the element's own
It is read off the resolved
direction when a sideways arrow arrives, so a slider inside a dir="auto" subtree behaves the way it looks.Vertical
A vertical slider counts up from its bottom edge, whatever the reading order — the block axis has no start to mirror. Give it a height; the default is
10rem.JSX
<Slider label="Bitrate" orientation="vertical" defaultValue={60} height={40} />Change and commit
onValueChange fires on every step of a drag, which is what a live preview wants and what a network request does not. onValueCommit fires once, when the pointer is let go or the key comes back up — the place for the request.committed:
40JSX
<Slider label="Quality" defaultValue={40} onValueChange={setPreview} onValueCommit={(value) => save(value)} />Hold the value next to the slider
A controlled slider re-renders whatever component owns its
useState, thirty times a second while an arrow is held. These demos each keep their own value for that reason: with the three of them in the page component, one arrow key re-rendered every slider, code block and table on this page — 4.2ms of JavaScript per keystroke against 0.3ms, and 16ms to paint instead of 32ms. Uncontrolled (defaultValue plus onValueCommit) costs nothing at all.Both report why:
{ reason: 'pointer' } or { reason: 'keyboard' }, beside the event that caused it.Steps and ranges
step is the grid every value lands on, counted from min rather than from zero, and step={0} is a continuous slider. Home and End go to the ends themselves even when those are off the grid.JSX
<Slider label="Rating" min={1} max={5} step={1} defaultValue={3} format={(value) => `${value} of 5`} />Naming a thumb
A
role="slider" has to have a name — a value nobody can attach to anything is not readable. On one thumb label names the thumb. On a range it names the role="group" around the thumbs, and thumbLabels names them one at a time: "Lowest" and "Highest" are what a reader hears before the number.Where the number alone does not read as the value,
format writes aria-valuetext — a currency, a date, a rating out of five.Styling
Four parts, each a key in
Box.components(): slider, slider.track, slider.fill and slider.thumb. The root takes Box props directly, and every part carries the vertical variant, because a widget whose track turned its axis while its thumb kept the old one is a bug this library has already shipped once.JSX
Box.components({
slider: {
children: {
fill: { styles: { bgGradient: { linear: 'r', colors: ['sky-400', 'indigo-500'] } } },
thumb: { styles: { borderColor: 'sky-500', width: 5, height: 5, ms: -2.5 } },
},
},
});Slider props
Everything below is this component’s own. All 235 of Box’s style props work on it too, and those are on /box rather than repeated here.
| Prop | Type | Default | What it does |
|---|---|---|---|
value | TValue | — | Controlled value. A number is one thumb and an array is one per entry; leave it out and the widget owns it. |
defaultValue | TValue | — | What it starts at, and what decides how many thumbs there are. Default min. |
onValueChange | ChangeHandler<TValue, SliderReason> | — | Fires on every step of a drag and every arrow key, in the shape the value was given in. |
onValueCommit | ChangeHandler<TValue, SliderReason> | — | Fires once when the interaction ends — the place for a request the live value is too noisy for. |
min | number | 0 | The bottom of the range. Default 0. |
max | number | 100 | The top of the range. Default 100. |
step | number | 1 | The grid every value lands on. Default 1; 0 is a continuous slider. |
largeStep | number | step * 10 | What Page Up and Page Down move by. Default ten steps. |
orientation | SliderOrientation | 'horizontal' | Which way the thumbs run. Default horizontal, whose arrows follow the reading order. |
disabled | boolean | false | Not adjustable, by pointer or by key. It is aria-disabled, so the thumb keeps its place in the tab order and stays readable. |
label | string | — | What the slider is called. On one thumb it names the thumb; on several it names the group around them. |
labelledBy | string | — | The same, naming an element that already says it. |
thumbLabels | readonly string[] | — | One name per thumb — "Minimum", "Maximum". A range wants them: label names the group, not the thumbs. |
format | (value: number) => string | — | The value as it should be read out, when the number alone is not it: a currency, a date, a rating. |
name | string | — | What the value submits under. One hidden input per thumb, so a range posts two values of that name. |
Slider keyboard
| Key | Result |
|---|---|
Tab | Every thumb is its own tab stop, so a range is two of them. |
Right / Up | One step towards the maximum. In a right-to-left page Left is the increase, and Up never changes: the block axis has no reading order. |
Left / Down | One step towards the minimum. |
PageUp / PageDown | largeStep, ten steps unless it says otherwise. |
Home / End | The minimum and the maximum themselves, on or off the step grid. |
Slider accessibility
Implements the WAI-ARIA APG pattern.
- Each thumb is a
role="slider"carryingaria-valuemin,aria-valuemax,aria-valuenowandaria-orientation;formatwritesaria-valuetextfor a value a bare number does not read as. - A thumb has to be named, so
labelnames the one on a single slider — and on a range it names therole="group"around the thumbs, wherethumbLabelsnames them one at a time. disabledisaria-disabledrather than the attribute: a slider a reader cannot reach is a value they cannot read, and a div takes nodisabledanyway.
Swept with axe on every release, in this state:
Slider. No violations, with contrast and landmark rules left to a human. Screen-reader results are not published yet.Slider style tree
Every part the component draws is a node with a name, so a default can be restyled with
Box.components() instead of a selector — and a variant is a name too.slidervariants: vertical, disabledslider.trackvariants: verticalslider.fillvariants: vertical, trackingslider.thumbvariants: vertical, disabled, tracking