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.json

Usage

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

  1. Ordered pair, never flipped. The value is { lo, hi } with min <= 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 — onChange only ever fires with a valid pair.
  2. 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 of role="slider".
  3. 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.
  4. CSS-variable highlighted segment. The painted track between the thumbs is a sibling div whose left / right are CSS-variable–driven percentages of (lo, hi). No JS layout measurement, no resize observer.
  5. Controlled + uncontrolled. Pass value for fully controlled use, or defaultValue alone for a self-owned bracket. Missing keys fall back to { lo: min, hi: max }.
  6. 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

PropTypeDefaultDescription
minnumberrequiredInclusive lower bound of the domain.
maxnumberrequiredInclusive upper bound of the domain.
stepnumber1Step granularity for both thumbs.
valueRangeValueControlled { lo, hi } pair. Pair with onChange.
defaultValueRangeValue{ lo: min, hi: max }Uncontrolled initial pair.
onChange(value: RangeValue) => voidFired with the next pair whenever either thumb moves. The component guarantees min <= lo <= hi <= max on every emit.
labelReactNodeOptional label rendered above the track, tied to both thumbs via aria-labelledby.
formatValue((value: RangeValue) => string) | null(v) => lo – hiReadout formatter. Pass null to suppress the readout.
disabledbooleanfalseDisables both thumbs.
ariaLabelLostring"Minimum value"Accessible name for the low thumb.
ariaLabelHistring"Maximum value"Accessible name for the high thumb.
classNamestringMerged 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. When label is provided, the group is wired to it via aria-labelledby.
  • Each thumb carries its own aria-label (ariaLabelLo / ariaLabelHi) so the announcer distinguishes "Minimum value" from "Maximum value".
  • aria-valuemin, aria-valuemax, and aria-valuenow mirror 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-muted halo).

Credits

  • Extracted from: algoflashcards (src/lessons/primitives/interaction/RangeInput.tsx). The source was a single-value styled range with a per-call trackHex token and a unit suffix. 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 by LabeledSlider; this primitive owns the two-thumb case.