# Slider

_Box Kite 2.1.0 · a markdown copy of https://www.box-kite.dev/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**

```jsx
<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.

```jsx
<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.

```jsx
<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](https://www.box-kite.dev/box.md) 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](https://www.w3.org/WAI/ARIA/apg/patterns/slider/).

- Each thumb is a `role="slider"` carrying `aria-valuemin`, `aria-valuemax`, `aria-valuenow` and `aria-orientation`; `format` writes `aria-valuetext` for a value a bare number does not read as.
- A thumb has to be named, so `label` names the one on a single slider — and on a range it names the `role="group"` around the thumbs, where `thumbLabels` names them one at a time.
- `disabled` is `aria-disabled` rather than the attribute: a slider a reader cannot reach is a value they cannot read, and a div takes no `disabled` anyway.

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.

`slider`variants: vertical, disabled

`slider.track`variants: vertical

`slider.fill`variants: vertical, tracking

`slider.thumb`variants: vertical, disabled, tracking

---

_Every page: https://www.box-kite.dev/llms.txt · every prop, measured: https://www.box-kite.dev/props.md_

