Skip to main content

Slider

Implemented

Range slider — single or dual thumb. 4px track, 20px thumb. Keyboard arrow keys, ARIA slider pattern.

Categorymolecules
Sourcepatterns/molecules/slider.html
AI HintUse for grouped UI patterns. Combine atoms into functional units.

Continuous 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

Volume

Single thumb — formatValue + step

Price cap

Single thumb — disabled

Price cap

Dual thumb — [number, number] value

Ticket price range

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 = the formatValue output, so non-numeric labels like “Medium” work). The single thumb is named via aria-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-min canonical. Step-6 fix queued (44px hit area, 20px visual thumb preserved).
  • Single-thumb display is unclamped (F4). If a consumer passes a value outside [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

  • formatValue is the one i18n hook that exists today — it drives both the visible readout and aria-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/maxLabel props are queued in the step-6 pass (F10), mirroring the StatisticsDateFilter labels pattern.

Props

Prop
Type
Default
Description
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]) => void
—

Controlled change handler. Fires with the snapped + clamped value, in the same shape as the value prop.

min*
number
—

Minimum allowed value (inclusive).

max*
number
—

Maximum allowed value (inclusive).

step
number
1

Step increment. Values snap to min + n×step; must be > 0 to have any effect.

label*
string
—

Visible 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) => string
—

Formats the visible readout and aria-valuetext (currency, localized numbers). Defaults to String(v).

disabled
boolean
false

Disables the underlying native inputs and dims the thumbs.

id
string
—

Stable id used as the prefix for the generated label/readout element ids. Defaults to slider-{React.useId()}.

className
string
—

Accepted for API symmetry but currently not applied to any element — wiring is queued with the step-6 fixes.

Props

Preview

Slider

Prop
Type
Required
Default
Description
value
number | [number, number]
Required
—
No description.
onChange
(value: number | [number, number]) => void
Required
—
No description.
label
string
Required
—
No description.
min
number
Required
—

Minimum allowed value.

max
number
Required
—

Maximum allowed value.

step
number
Optional
1

Step increment.

formatValue
(v: number) => string
Optional
—

Synced from source — no interface JSDoc yet; document on `Slider.tsx`.

disabled
boolean
Optional
false

Disables the control.

id
string
Optional
'slider-{useId()}'

Stable id prefix for generated label/readout ids.

className
string
Optional
—

API symmetry only — not applied yet (wiring queued).

Quick use:<Slider />

Accessibility

ARIA roleslider

Type

1 rule

NASA Rule 7

value in [min, max]; step > 0 (NASA Rule 7)

ARIA

1 rule

WAI-ARIA Slider Pattern

role='slider' aria-valuenow/min/max; for range, two role='slider' thumbs

Usage

Import

tsxSnippet
import { Slider } from "@epasslive/design-system"

Example 1

tsxSnippet
<Slider />

Example 2

tsxSnippet
<Slider variant="default" />

Anti-patterns

— Do not hardcode data in molecules

— Avoid complex business logic in molecules

Design System v4.1.0 | ePassLive