Slider

A Radix-style slider. The value is a number[] — one entry per thumb — paired with onValueChange. [v] renders a single-thumb bar that paints from min to v; [lo, hi] renders a two-thumb range with the fill between them. Distinct from LabeledSlider (single-value primitive with a built-in label + readout) and RangeInput (a strict { lo, hi } pair under the min <= lo <= hi <= max invariant).

Volume42%
Bracket2080

Installation

npx shadcn@latest add https://craftbits.dev/r/slider.json

Usage

Single-thumb — pair value with onValueChange. The fill grows from min to the thumb:

import { Slider } from "@craft-bits/core";
 
const [volume, setVolume] = useState<number[]>([42]);
 
<Slider
  min={0}
  max={100}
  step={1}
  value={volume}
  onValueChange={setVolume}
  aria-label="Volume"
/>

Two-thumb — same primitive, just two entries in the array. The fill paints between the two thumbs:

const [bracket, setBracket] = useState<number[]>([20, 80]);
 
<Slider
  min={0}
  max={100}
  value={bracket}
  onValueChange={setBracket}
  aria-label="Bracket"
/>

Uncontrolled — pass defaultValue alone and let the component own its array:

<Slider min={0} max={100} defaultValue={[35]} aria-label="Brightness" />

Understanding the component

  1. Radix-style contract. value: number[] + onValueChange: (value: number[]) => void mirrors @radix-ui/react-slider's Root API, so callers migrating off Radix (or off the source project's radix-ui umbrella) can swap the import without re-wiring state.
  2. Thumb count is the array length. [v] is a one-thumb bar; [lo, hi] is a two-thumb range; N > 2 is supported and the painted fill spans from the lowest to the highest entry.
  3. N native ranges, one track. Each thumb is a real <input type="range"> layered over the shared rail. Native arrow-key stepping, Home / End, PageUp / PageDown, and screen-reader role="slider" semantics ship for free.
  4. Independent thumbs even when overlapping. Pointer-events are disabled on the underlying inputs and re-enabled on the thumbs alone, so adjacent thumbs stay individually grabbable when their values coincide.
  5. CSS-variable painted fill. The highlighted segment is a sibling div whose left / right are CSS-variable–driven percentages of the lowest / highest entry. No JS layout measurement.
  6. Controlled + uncontrolled. Pass value for controlled use, or defaultValue alone for a self-owned array. Missing keys fall back to [min].
  7. Token-only theming. Track, fill, thumb, halo, and focus ring all read from --cb-* tokens.

Props

PropTypeDefaultDescription
minnumber0Inclusive lower bound of the slider domain.
maxnumber100Inclusive upper bound of the slider domain.
stepnumber1Step granularity for every thumb.
valuereadonly number[]Controlled value array. One entry per thumb. Pair with onValueChange.
defaultValuereadonly number[][min]Uncontrolled initial array.
onValueChange(value: number[]) => voidFired with the next array whenever any thumb moves. Entries are clamped into [min, max].
disabledbooleanfalseDisables every thumb.
aria-labelstring"Value"Accessible name applied to each thumb.
aria-labelledbystringOverrides aria-label per the ARIA precedence rules.
classNamestringMerged onto the outer wrapper.

Accessibility

  • Each thumb is a real <input type="range">role="slider", arrow-key stepping, Home / End jump to bounds, PageUp / PageDown step in larger increments. All browser-native.
  • The wrapper declares role="group" so screen readers announce the thumbs as a related set. aria-labelledby wins when provided, otherwise aria-label falls through.
  • Each thumb mirrors aria-valuemin, aria-valuemax, and aria-valuenow from React state.
  • The painted fill and rail are wrapped in aria-hidden="true" — the slider roles already announce the values.
  • All thumbs stay keyboard-reachable in DOM order even when their values coincide.
  • Focus is visible via a token-driven ring around each thumb (--cb-accent-muted halo).

Credits

  • Extracted from: algoflashcards (src/platform/ui/slider.tsx). The source was a thin wrapper around radix-ui's SliderPrimitive.Root. craft-bits drops the radix-ui umbrella dependency, rebuilds the same Radix-style contract (value: number[] + onValueChange) on N overlaid native <input type="range"> elements, and re-skins the chrome onto the shared --cb-* token system. Distinct from LabeledSlider (single-value with built-in label + unit readout) and RangeInput (strict { lo, hi } pair).