Skip to main content

Slider

betav0.1.0

Numeric value selection along a track, built on @radix-ui/react-slider. Renders the Figma Slider Field — optional label with an info slot, a 4px rail with a filled range and a 16px thumb, optional help text and an optional Min/Max row — from props, so no separate field wrapper is needed. The value is an array, which is the primitive's own contract: one thumb is rendered per entry, so a two-element value gives a range at no extra API cost — thumbLabels names each end so the two thumbs are told apart by screen readers. There is deliberately no error prop: validation state is carried by the surrounding form field. Ships without marks and without the value bubble — see Slider.rfc.md sections 7 and 13.

Variants

The Slider renders the whole Figma field from props — no separate field wrapper. Each example below adds one part.

Bare control, named by aria-label
aria-labeldefaultValue
A visible label becomes the accessible name
label
Confidence
Help text sits between the label and the track
helperText
Confidence
Between 0 and 100
Info affordance in the label row, consumer-provided
infoTooltip
Confidence
The info slot works like Input's slot of the same name
A custom scale and increment
minmaxstep
Release year
Steps of 5 between 1970 and 2020
Submitted with a form under a name
name
Confidence
Radix renders the hidden input

Examples

<Slider aria-label="Confidence" defaultValue={[50]} />
<Slider label="Confidence" defaultValue={[50]} min={0} max={100} step={5} />
<Slider label="Confidence" value={[score]} onValueChange={([v]) => setScore(v)} />
<Slider label="Confidence" defaultValue={[50]} onValueCommit={([v]) => save(v)} />
<Slider label="Score window" defaultValue={[25, 75]} />
<Slider label="Confidence" helperText="Between 0 and 100" defaultValue={[50]} />
<Slider label="Confidence" defaultValue={[50]} disabled />
<Slider label="Confidence" defaultValue={[50]} showBounds />
<Slider label="Heat" defaultValue={[2]} min={0} max={4} showBounds minLabel="Cold" maxLabel="Hot" />
<Slider label="Price" defaultValue={[25, 75]} thumbLabels={["Minimum", "Maximum"]} />
<Slider name="confidence" defaultValue={[50]} aria-label="Confidence" />

Props

PropType
valuenumber[] — Controlled value. One thumb is rendered per entry
defaultValuenumber[] — Default value (uncontrolled). Defaults to a single thumb
onValueChange(value: number[]) => void — Fires on every value change, including drag
onValueCommit(value: number[]) => void — Fires once the interaction ends, for expensive handlers
minnumber — Lower bound (default 0)
maxnumber — Upper bound (default 100)
stepnumber — Increment granularity (default 1)
minStepsBetweenThumbsnumber — Minimum distance, in steps, between two thumbs of a range value
labelReactNode — Visible label above the track, associated via aria-labelledby
infoTooltipReactNode — Info affordance next to the label, consumer-provided like Input's slot of the same name. Label-row anatomy: not rendered without a label
helperTextReactNode — Help text under the label, above the track, wired via aria-describedby
showBoundsboolean — Renders the Figma Min/Max row under the track (hasMinMaxValue). Off by default, unlike Figma, so sliders already shipped are unchanged
minLabelReactNode — Replaces the lower bound text when showBounds is on. Defaults to min
maxLabelReactNode — Replaces the upper bound text when showBounds is on. Defaults to max
thumbLabelsstring[] — Accessible name per thumb, by index, for range values. Always appended to the field name, however that name is provided; unnamed thumbs keep the shared name
wrapperClassNamestring — Additional classes for the wrapper holding label, track and help text
disabledboolean — Disabled state. Removes the thumbs from the tab order
namestring — Name submitted with the owning form (Radix renders the hidden inputs)
invertedboolean — Fills the track from the opposite end
idstring — Overrides the auto-generated id used for label and help association
classNamestring — Additional classes for the slider root

Accessibility

2.1 AAWCAG pass

Radix renders each thumb with role="slider" and aria-valuemin / aria-valuemax / aria-valuenow, so the value is exposed to assistive technology rather than carried by position alone (WCAG 1.4.1). The full keyboard contract comes from the primitive: Arrow keys step by `step`, Home and End jump to min and max, PageUp and PageDown take a larger step. forwardRef is exposed on the Radix Root. The visible label is associated through aria-labelledby on every thumb rather than a native <label htmlFor>, because the element carrying role=slider is a span, not a form control; the help text is wired the same way through aria-describedby. When no label is given, aria-label and aria-labelledby are forwarded to every thumb so an unlabelled Slider can still be named. ONE DELIBERATE DEVIATION FROM FIGMA, declared in Slider.figma.json with its rationale: the Figma Focus symbol carries only the 24x24 transparency halo on the thumb, with no ring anywhere in the node. The implementation keeps that halo and adds the repo's standard focus ring on top. This strengthens conformance rather than weakening it — the halo alone contrasts 1.17:1 dark / 1.22:1 light against the reference surface and could not satisfy SC 2.4.13 or SC 1.4.11 on its own, while the standard ring contrasts 10.30:1 / 11.22:1. DISABLED SUBSTITUTES A COLOUR ON THE CONTROL, since the Figma edit of 2026-08-04. The filled range (2721:4740) and the thumb (2721:4741) move from --icon-highlight to --icon-disabled, read in both modes on the Disabled symbols (dark 2686:6325, light 6682:24835). That token exposes no bg-* utility, so it is resolved natively by text-default-disabled + bg-current — the pairing Button, Checkbox and Radio already use with border-current for the same situation. This closes GAP-S2, which had reported the absence of any substitution; the gap entry was removed rather than left standing. ONE DIVERGENCE REMAINS DECLARED (S14): the rail frame also carries a raw 50% layer opacity, deliberately not reproduced — arbitration Q8 rules that disabled is colour substitution and never raw opacity, and the opacity carries no variable binding. The rail itself keeps --color-feedback-neutral-secondary, unchanged by the edit. THE RAIL IS NOT ELEVATION-AWARE. --color-feedback-neutral-secondary is defined in @theme, in .light and in .dark, and in no .layer-N block, so the rail keeps the same value on every elevation in both modes. Recorded as GAP-S1, not worked around. Disabled state is exempt from contrast (WCAG 1.4.3 Note 1, inactive UI components): --text-input-disabled and --icon-disabled are reached exclusively through the disabled branch. The disabled text pair is declared anyway. MEASURED RATIOS, computed by pnpm check:contrast against the standard reference surface, dark then light. Everything that carries the control, its value or its text is above threshold: filled range and thumb 10.30:1 / 11.22:1; focus ring 10.30:1 / 11.22:1; label text 8.99:1 / 7.89:1; help text 8.99:1 / 7.89:1; label and help text when disabled 9.38:1 / 7.20:1; filled range and thumb when disabled 9.38:1 / 7.20:1 (disabled is exempt under WCAG 1.4.3 Note 1, but the pair is declared and it clears 3:1 anyway). TWO SUPPLEMENTAL treatments are deliberately not declared as pairs. The hover and focus halo, which measures 1.17:1 dark / 1.22:1 light. It is not required to identify the control or its value — the thumb and the filled range do that at 10.30:1 / 11.22:1 — and it could not clear 3:1 without ceasing to read as a halo. Understanding SC 1.4.11's 'Hover states' guidance is explicit that author-supplied hover treatments can be considered supplemental. Its Rule 5 coverage is signed off in scripts/contrast-coverage-allowlist.json (dated, with a removal condition) — the coverage mechanism, not a WCAG derogation: there is no wcag-allowlist entry for this component. THE TRACK RAIL is the second: it measures 3.25:1 dark (still passing on its own merits, declared normally when this component was written) but 2.63:1 light, after PR #76 (Switch) rebound the light --color-feedback-neutral-secondary to --gray-400 on main before this branch rebased onto it — found by check:contrast right after the rebase, not before it. A ContrastPair cannot be declared per-mode, so it moved under the same coverage mechanism as the halo, dated 2026-08-05, with the same removal condition. wcagStatus is pass because the component is conformant, not by exemption. The underlying tooling gap — ContrastPair cannot mark a pair supplemental, so a supplemental treatment can only be omitted or counted against the verdict — is tracked HIGH PRIORITY in process/AI-BACKLOG.md. marks, the value bubble, vertical orientation and size variants are excluded — see Slider.rfc.md section 7.