Slider
ImplementedRange slider — single or dual thumb. 4px track, 20px thumb. Keyboard arrow keys, ARIA slider pattern.
patterns/molecules/slider.htmlContinuous magnitude selection over a native input[type=range]: single thumb (number value) or dual-thumb range ([lo, hi] tuple). Controlled-only — there is no defaultValue. The dual-thumb variant is currently keyboard-operable only; see Known limitations below.
Slider
Form-control molecule (native input[type=range] base)
One component serves both shapes: pass a number for a single thumb, a [number, number] tuple for a min/max pair. Values are snapped to step and clamped to [min, max] before onChange fires. For discrete quantities use Stepper; for date ranges use DateRangePicker.
Single thumb — default numeric readout
Single thumb — formatValue + step
Single thumb — disabled
Dual thumb — [number, number] value
Keyboard-operable only — Tab to a thumb, then use the arrow keys. Pointer dragging is a known limitation; fix queued (step 6).
Usage
Slider is controlled-only: value and onChange are both required and there is no defaultValue. The value union is narrowed with a guard, not a cast.
import { Slider } from '@epasslive/design-system'
// Single thumb — controlled, with a formatted readout
function PriceCap() {
const [cap, setCap] = useState(60)
return (
<Slider
label="Price cap"
min={0}
max={250}
step={5}
value={cap}
onChange={v => {
if (typeof v === 'number') setCap(v)
}}
formatValue={v => `$${v}`}
/>
)
}
// Dual thumb — value is a [lo, hi] tuple.
// NOTE: keyboard-operable only (known limitation — pointer fix queued).
function PriceRange() {
const [range, setRange] = useState<[number, number]>([40, 160])
return (
<Slider
label="Ticket price range"
min={0}
max={250}
step={5}
value={range}
onChange={v => {
if (Array.isArray(v)) setRange(v)
}}
formatValue={v => `$${v}`}
/>
)
}Keyboard interaction
- Built on native
<input type="range">— keyboard support comes from the platform, there is no custom key handling. - Single thumb: Tab focuses the slider; ←/→ (and ↓/↑) move by
step; Home/End jump to min/max; PageUp/PageDown are browser-defined (Chromium moves ~10% of the range, not 10× step). - Dual thumb: two native inputs stacked in DOM order — Tab reaches the minimum thumb first, then the maximum thumb, then leaves the control. Each thumb supports the same arrow/Home/End keys.
- Cross-thumb semantics: pushing lo past hi collapses the range to
[v, v](hi follows lo) — the thumbs never cross. - ARIA: each input exposes the implicit slider role with aria-valuemin/valuenow/valuetext (
aria-valuetext= theformatValueoutput, so non-numeric labels like “Medium” work). The single thumb is named viaaria-labelledby→ visible label; range thumbs are named “{label} minimum” / “{label} maximum”.
Known limitations
Honest current state of the component — each item has a fix queued in the Slider step-6 source pass. Documented here so adopters can decide with full information.
- Dual thumb is pointer-dead (F1). Both range inputs render with
pointerEvents: 'none'and the intended rescue class has no CSS definition — the range variant cannot be dragged with mouse or touch. Keyboard-only until the step-6 CSS fix lands. (WCAG 2.5.1 Pointer, Level A.) - No visible focus indicator (F2). Focus outlines are stripped and the fallback ring can never fire (it requires focus inside the thumb element, but the thumb is a sibling of the input). Keyboard focus is invisible on both variants. (WCAG 2.4.7.) Step-6 fix queued.
- Touch target is 20px (F3). The interactive input is 20px tall and the visual thumb is 20×20px — below WCAG 2.5.8 (24px) and the repo's 44px
--ep-tap-mincanonical. Step-6 fix queued (44px hit area, 20px visual thumb preserved). - Single-thumb display is unclamped (F4). If a consumer passes a
valueoutside [min, max], the fill/thumb render outside the track on the single-thumb variant (the range variant clamps for display). onChange output is always clamped — the defect is display-only. Step-6 fix queued.
Internationalization
formatValueis the one i18n hook that exists today — it drives both the visible readout andaria-valuetext, so localized number/currency formatting works:const priceFmt = new Intl.NumberFormat('fr-CA', { style: 'currency', currency: 'CAD', maximumFractionDigits: 0, }) <Slider /* … */ formatValue={v => priceFmt.format(v)} />- Range-thumb accessible names are built by appending hardcoded English suffixes — “{label} minimum” / “{label} maximum” — so non-English routes announce English. No override props exist yet;
minLabel/maxLabelprops are queued in the step-6 pass (F10), mirroring the StatisticsDateFilter labels pattern.
Props
value*number | [number, number]Controlled value. A number renders the single-thumb variant; a [lo, hi] tuple renders the dual-thumb range variant.
onChange*(value: number | [number, number]) => voidControlled change handler. Fires with the snapped + clamped value, in the same shape as the value prop.
min*numberMinimum allowed value (inclusive).
max*numberMaximum allowed value (inclusive).
stepnumber1Step increment. Values snap to min + n×step; must be > 0 to have any effect.
label*stringVisible label above the track. Also feeds the accessible name: the single thumb links it via aria-labelledby; range thumbs announce "{label} minimum" / "{label} maximum" (hardcoded English suffixes — see the i18n note below).
formatValue(v: number) => stringFormats the visible readout and aria-valuetext (currency, localized numbers). Defaults to String(v).
disabledbooleanfalseDisables the underlying native inputs and dims the thumbs.
idstringStable id used as the prefix for the generated label/readout element ids. Defaults to slider-{React.useId()}.
classNamestringAccepted for API symmetry but currently not applied to any element — wiring is queued with the step-6 fixes.
Props
Slider
valuenumber | [number, number]—onChange(value: number | [number, number]) => void—labelstring—minnumber—Minimum allowed value.
maxnumber—Maximum allowed value.
stepnumber1Step increment.
formatValue(v: number) => string—Synced from source — no interface JSDoc yet; document on `Slider.tsx`.
disabledbooleanfalseDisables the control.
idstring'slider-{useId()}'Stable id prefix for generated label/readout ids.
classNamestring—API symmetry only — not applied yet (wiring queued).
<Slider />Accessibility
Type
1 ruleARIA
1 ruleUsage
Import
import { Slider } from "@epasslive/design-system"Example 1
<Slider />Example 2
<Slider variant="default" />Anti-patterns
— Do not hardcode data in molecules
— Avoid complex business logic in molecules