Range Input
A dual-thumb numeric range picker. The value is a { lo, hi } pair under the invariant min <= lo <= hi <= max — the component enforces the constraint on every update so consumers never have to defend against a flipped pair. Distinct from ScrubberCursor, which is a single-value cursor on a normalised [0, 1] track.
Preview
Price bracket$2,000 – $7,500
Installation
npx shadcn@latest add https://craftbits.dev/r/range-input.jsonUsage
Controlled — pair value with onChange so a parent owns the bracket state:
import { RangeInput, type RangeValue } from "@craft-bits/core";
const [bracket, setBracket] = useState<RangeValue>({ lo: 2000, hi: 7500 });
<RangeInput
min={0}
max={10000}
step={250}
value={bracket}
onChange={setBracket}
label="Price bracket"
/>Uncontrolled — let the component own the pair, seed it via defaultValue:
<RangeInput
min={0}
max={100}
defaultValue={{ lo: 20, hi: 80 }}
label="Sample window"
/>Format the readout — pass formatValue that receives the { lo, hi } pair, or set it to null to hide the readout:
<RangeInput
min={0}
max={10000}
step={250}
value={bracket}
onChange={setBracket}
formatValue={(v) => "$" + v.lo + " – $" + v.hi}
/>Understanding the component
- Ordered pair, never flipped. The value is
{ lo, hi }withmin <= lo <= hi <= max. When the user drags the low thumb past the high thumb (or vice versa), the moving thumb pins to the stationary one —onChangeonly ever fires with a valid pair. - Two native ranges, one track. Each thumb is a real
<input type="range">. Native arrow-key stepping, Home / End, PageUp / PageDown, and screen-reader semantics ship for free per thumb — no JS rebuild ofrole="slider". - Independent thumbs even when overlapping. Pointer-events are disabled on the underlying inputs and re-enabled on the thumbs alone, so both thumbs stay individually grabbable when their values coincide.
- CSS-variable highlighted segment. The painted track between the thumbs is a sibling div whose
left/rightare CSS-variable–driven percentages of (lo, hi). No JS layout measurement, no resize observer. - Controlled + uncontrolled. Pass
valuefor fully controlled use, ordefaultValuealone for a self-owned bracket. Missing keys fall back to{ lo: min, hi: max }. - Token-only theming. Track, fill, thumb, halo, and focus ring all read from
--cb-*tokens. Theme swaps and dark mode repaint without a prop change.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
min | number | required | Inclusive lower bound of the domain. |
max | number | required | Inclusive upper bound of the domain. |
step | number | 1 | Step granularity for both thumbs. |
value | RangeValue | — | Controlled { lo, hi } pair. Pair with onChange. |
defaultValue | RangeValue | { lo: min, hi: max } | Uncontrolled initial pair. |
onChange | (value: RangeValue) => void | — | Fired with the next pair whenever either thumb moves. The component guarantees min <= lo <= hi <= max on every emit. |
label | ReactNode | — | Optional label rendered above the track, tied to both thumbs via aria-labelledby. |
formatValue | ((value: RangeValue) => string) | null | (v) => lo – hi | Readout formatter. Pass null to suppress the readout. |
disabled | boolean | false | Disables both thumbs. |
ariaLabelLo | string | "Minimum value" | Accessible name for the low thumb. |
ariaLabelHi | string | "Maximum value" | Accessible name for the high thumb. |
className | string | — | Merged onto the outer wrapper. |
The RangeValue shape is:
interface RangeValue {
lo: number;
hi: number;
}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. - The wrapper declares
role="group"so screen readers announce the two thumbs as a single related set. Whenlabelis provided, the group is wired to it viaaria-labelledby. - Each thumb carries its own
aria-label(ariaLabelLo/ariaLabelHi) so the announcer distinguishes "Minimum value" from "Maximum value". aria-valuemin,aria-valuemax, andaria-valuenowmirror state per thumb.- The painted fill and rail are wrapped in
aria-hidden="true"— the two slider roles already announce the values. - Both thumbs stay keyboard-reachable in DOM order (low first, then high), even when the two values coincide and the visible thumbs overlap.
- Focus is visible via a token-driven ring around each thumb (
--cb-accent-mutedhalo).
Credits
- Extracted from:
algoflashcards(src/lessons/primitives/interaction/RangeInput.tsx). The source was a single-value styled range with a per-calltrackHextoken and aunitsuffix. craft-bits generalises it to a two-thumb numeric range under the{ lo, hi }invariant, drops the hex plumbing onto the shared--cb-*token system, and adds the cross-thumb pinning logic the original couldn't express with a single input. The single-value variant remains covered byLabeledSlider; this primitive owns the two-thumb case.