Slider
Accessible single-thumb and range slider following WAI-ARIA APG Slider patterns, with compile-time per-thumb name enforcement, automatic aria-valuetext, and dynamic dependent aria-valuemin/aria-valuemax.
npx artui@latest add sliderPlayground
Usage
Single thumb with formatted value
import { Slider } from '@/components/slider';
<Slider
min={0}
max={1000}
step={10}
defaultValue={250}
aria-label="Maximum price"
formatValue={(v) => `€${v}`}
/>Range with per-thumb labels
import { Slider } from '@/components/slider';
<Slider
min={0}
max={1000}
step={10}
defaultValue={[100, 750]}
aria-label="Price range"
formatValue={(v) => `€${v}`}
thumbs={[
{ 'aria-label': 'Minimum price' },
{ 'aria-label': 'Maximum price' },
]}
/>Each thumb in thumbs must carry an accessible name. Omitting both aria-label and aria-labelledby is a TypeScript compile error.
Controlled
import { Slider } from '@/components/slider';
const [budget, setBudget] = useState(500);
<Slider
min={0}
max={1000}
step={10}
value={budget}
onValueChange={setBudget}
aria-label="Budget"
formatValue={(v) => `€${v}`}
/>Non-numeric domain
import { Slider } from '@/components/slider';
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
<Slider
min={0}
max={6}
step={1}
defaultValue={1}
aria-label="Pickup day"
formatValue={(v) => WEEKDAYS[v]}
/>formatValue wires the returned string to aria-valuetext automatically so screen readers announce "Monday" instead of "1".
Vertical orientation
import { Slider } from '@/components/slider';
<Slider
min={0}
max={100}
step={1}
defaultValue={50}
orientation="vertical"
aria-label="Volume"
/>API
SliderProps
SliderProps is a discriminated union: when thumbs is present the component enters range mode and value / defaultValue / onValueChange accept tuples. When thumbs is absent it is single-thumb mode and those props accept scalars.
| Prop | Type | Default | Description |
|---|---|---|---|
| min | number | 0 | Minimum value. |
| max | number | 100 | Maximum value. |
| step | number | 1 | Arrow-key step size. A dev overlay fires when step ≤ 0 or step > (max - min). |
| largeStep | number | max(step * 10, (max - min) / 10) | PageUp / PageDown jump size. |
| disabled | boolean | false | Disables interaction. Uses aria-disabled rather than the HTML disabled attribute so the thumb stays in the tab order for AT inspection. |
| orientation | "horizontal" | "vertical" | "horizontal" | Slider orientation. |
| formatValue | (value: number, thumbIndex: number) => string | none | When provided, the returned string is wired to aria-valuetext on every thumb automatically. Use for non-numeric domains (e.g. '€250', 'Monday'). |
| value | number | readonly [number, number] | none | Controlled value. Pair with onValueChange. |
| defaultValue | number | readonly [number, number] | none | Uncontrolled initial value. |
| onValueChange | ((value: number) => void) | ((value: readonly [number, number]) => void) | none | Called whenever the value changes. |
| thumbs | readonly [SliderThumbDescriptor, SliderThumbDescriptor] | none | Range mode: exactly two thumb descriptors. Each must satisfy AccessibleNameProps: passing neither children, aria-label, nor aria-labelledby is a compile error. |
| aria-label | string | none | Single-thumb: accessible name on the thumb button. Range: accessible name on the group wrapper. One of aria-label or aria-labelledby is required in range mode. |
| aria-labelledby | string | none | Single-thumb: ID of an element that labels the thumb. Range: ID of the element that labels the group. A dev overlay fires when the ID does not resolve to a DOM element. |
| showValues | boolean | false | When true, visually displays the min/max bounds at the track ends and the live thumb value above each thumb. All visible text is aria-hidden; screen readers already receive the values via aria-valuenow and aria-valuetext on the thumb buttons. Formatted through formatValue when provided. |
| className | string | none | Additional CSS class applied to the slider root element. |
Range thumb swap
When a range thumb is dragged or moved by keyboard past the other thumb's current value, the component swaps focus and pointer capture to the other thumb so the gesture continues without interruption. No step is lost and no re-press is required.
What the swap guarantees:
- Focus follows the moving value: the thumb now in motion receives focus automatically.
- The full step is always applied: the value does not freeze at the crossing point.
- Screen readers announce the new thumb on focus change, so AT users hear "Minimum price: €300" rather than silence.
aria-valueminandaria-valuemaxrecompute on every render based on current value ordering, so each thumb always reports an accurate range boundary.
Keyboard
| Key | Action |
|---|---|
| Arrow Right+Arrow Up | Increase focused thumb value by step |
| Arrow Left+Arrow Down | Decrease focused thumb value by step |
| Page Up | Increase focused thumb value by largeStep |
| Page Down | Decrease focused thumb value by largeStep |
| Home | Move focused thumb to its effective minimum |
| End | Move focused thumb to its effective maximum |
| Tab | Move focus to the next focusable element outside the slider |
| Shift+Tab | Move focus to the previous focusable element outside the slider |
In range mode, when any key would push a thumb past the other thumb's value, focus transfers automatically to the other thumb and the full step is applied. Tab order between the two thumbs always follows DOM order (thumbs[0] first, thumbs[1] second), regardless of which has the lower value at that moment.
Accessibility
| Criterion | Name | How the component satisfies it |
|---|---|---|
| 1.3.1(A) | Info and Relationships | Single-thumb sliders inherit their accessible name from AccessibleNameProps (compile-time required). Range sliders wrap in role="group" with aria-label or aria-labelledby; a dev overlay fires when neither is provided or when aria-labelledby points to a missing DOM element. |
| 2.1.1(A) | Keyboard | ArrowLeft/Right/Up/Down move the focused thumb by step. PageUp/PageDown move by largeStep (defaults to max(step*10, (max-min)/10) per APG). Home/End jump to the effective min/max. A dev overlay fires when step is invalid. |
| 2.1.2(A) | No Keyboard Trap | Each thumb is a native <button> in the natural tab order. No focus trap is installed; Tab and Shift+Tab always leave the slider. |
| 2.3.3(AAA) | Animation from Interactions | Track-fill and thumb position transitions are gated on @media (prefers-reduced-motion: no-preference). With reduced motion the slider snaps without animation. |
| 2.4.3(A) | Focus Order | Tab order is constant regardless of visual thumb position. Thumb buttons are always rendered in the same DOM order (thumbs[0] first, thumbs[1] second); visual overlap is handled by z-index only. |
| 2.4.7(AA) | Focus Visible | Focus indicators use outline (not box-shadow) so they are visible in Windows High Contrast Mode. |
| 2.5.5(AAA) | Target Size (Enhanced) | Thumb buttons have a 44×44 CSS px hit area via a ::before pseudo-element, satisfying the recommended target size. The visual disc is smaller for aesthetics. |
| 3.2.2(A) | On Input | Adjusting the value never causes a context change: onValueChange is a local state update only. |
| 4.1.2(A) | Name, Role, Value | Each thumb has role="slider", aria-valuemin, aria-valuemax, aria-valuenow, aria-orientation, and its accessible name. aria-valuetext is wired automatically when formatValue is supplied. Range mode: per-thumb names enforced at compile time via the readonly [SliderThumbDescriptor, SliderThumbDescriptor] tuple. When a thumb's value crosses the other thumb's value (via keyboard or pointer drag), focus and value-identity transfer to the other thumb so the gesture continues seamlessly. Each thumb's aria-valuemin and aria-valuemax recompute on every render based on the current value ordering. Dev overlays fire for missing thumb names, out-of-range values, and type mismatches. |
| 1.4.11(AA) | Non-text Contrast | Bundled CSS uses system color tokens (Canvas, CanvasText, currentColor) that maintain 3:1 non-text contrast. Windows High Contrast Mode overrides apply ButtonFace/ButtonText/Highlight tokens. |
Do
Provide aria-label or aria-labelledby on every Slider
<Slider
min={0}
max={100}
defaultValue={40}
aria-label="Download speed limit"
/>Use formatValue for any value that is not self-describing as a raw number
<Slider
min={0}
max={1000}
step={10}
defaultValue={250}
aria-label="Maximum price"
formatValue={(v) => `€${v}`}
/>Screen readers announce "€250" instead of "250", giving users the unit and context they need.
Name each range thumb individually via thumbs descriptors
<Slider
min={0}
max={1000}
step={10}
defaultValue={[100, 750]}
aria-label="Price range"
thumbs={[
{ 'aria-label': 'Minimum price' },
{ 'aria-label': 'Maximum price' },
]}
/>Distinct names let screen reader users distinguish which thumb they are operating without guessing from position.
Sort the tuple in onValueChange if your downstream code expects a sorted range
<Slider
min={0}
max={100}
defaultValue={[20, 80]}
aria-label="Temperature range"
formatValue={(v) => `${v}°C`}
thumbs={[
{ 'aria-label': 'Minimum temperature' },
{ 'aria-label': 'Maximum temperature' },
]}
onValueChange={([a, b]) => setRange([Math.min(a, b), Math.max(a, b)])}
/>After a swap, thumbs[0] may hold the higher value. Sorting in onValueChange keeps your state predictable without any AT impact.
Don't
Omit accessible names from range thumbs
<Slider
min={0}
max={100}
thumbs={[{}, {}]}
/>Empty SliderThumbDescriptor objects are a TypeScript compile error. In JavaScript runtimes a dev overlay fires with WCAG 4.1.2 [Slider:multi-without-thumb-names]. Without names, both thumbs are announced identically and users cannot tell them apart.
Render a range slider without a group label
<Slider
min={0}
max={1000}
thumbs={[
{ 'aria-label': 'Minimum price' },
{ 'aria-label': 'Maximum price' },
]}
/>Missing aria-label or aria-labelledby on the root triggers a dev overlay with WCAG 1.3.1 [Slider:range-without-group-name]. The two thumb names alone do not convey what the overall range represents.
Pass a value outside [min, max]
<Slider min={0} max={100} defaultValue={150} aria-label="Speed" />A dev overlay fires with WCAG 4.1.2 [Slider:value-out-of-range] and the value is clamped silently. The slider's reported aria-valuenow would be out of sync with what your UI displays.
Assume thumbs[0] always holds the lower value after a swap
// incorrect: assumes slot 0 is always the lower bound
const [range, setRange] = useState<readonly [number, number]>([20, 80]);
<Slider
min={0}
max={100}
aria-label="Price range"
value={range}
onValueChange={setRange}
thumbs={[
{ 'aria-label': 'Minimum price' },
{ 'aria-label': 'Maximum price' },
]}
/>
const lowerBound = range[0]; // wrong after a cross-overAfter a swap, value identity travels with focus while slot identity stays put, so thumbs[0] may hold the higher value. Sort in onValueChange if you need a guaranteed [lo, hi] tuple downstream.
Select
An accessible select with two modes. Pick one option from a styled native select, or several from a chip-based listbox. Both sit on real HTML elements, so forms and screen readers work without extra wiring.
Toast
Accessible toast notifications using WAI-ARIA live regions and the native Popover API. info / success / warning / error severity, pause-on-hover, no focus theft, compile-time enforcement of accessible titles.