Inputs
Range Slider
A two-thumb range selector with a minimum gap, value bubbles, marks and full keyboard control.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/range-slider.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "RangeSlider" component (no extra
libraries; pointer events only).
Contract
- Export a forwardRef div extending HTMLAttributes (minus defaultValue and
onChange).
- Controlled only: value: [number, number] and onValueChange(value) are
required; the component never owns the tuple.
- min (0), max (100), step (1), minGap (0), disabled, showTooltip,
marks?: number[], formatValue?: (n) => string, label (aria prefix,
default "Range"), onValueCommit?(value).
Behavior
- Geometry: percent = (value - min) / (max - min). One muted track, one
primary fill spanning the two thumbs, two absolutely positioned thumbs.
- Pointer: a single onPointerDown on the track hit-area handles both cases.
Compute the value under the pointer, pick the nearest thumb (on a tie, the
side the press is on), setPointerCapture on the hit-area, move that thumb
and focus it. onPointerMove keeps moving the captured thumb; onPointerUp /
onPointerCancel end the drag. Capture means no window listeners and
therefore nothing to clean up on unmount, and the drag survives the pointer
leaving the element.
- Every move snaps to the step grid (min + round((n - min) / step) * step,
rounded to step's own decimal places so 0.1 steps leave no float tails)
and clamps to that thumb's bounds: thumb 0 is capped at value[1] - minGap,
thumb 1 is floored at value[0] + minGap. The thumbs therefore push against
each other and can never cross.
- Keyboard on each thumb: ArrowRight/ArrowUp +step, ArrowLeft/ArrowDown
-step, PageUp/PageDown ±10*step, Home/End to that thumb's own bounds.
Keys go through the same clamp path as the pointer.
- onValueChange fires on every frame of a drag and on every key press;
onValueCommit fires once per settled interaction — on pointer release
(reading the last emitted tuple from a ref, since the prop may still be a
render behind) and immediately after a key press, since key steps are
discrete. Wire expensive work (refetching a product list) to commit.
- That ref is cleared when a gesture starts, not only when one ends: a key press
also writes it, so a later press on the track that moves nothing must commit
the current value rather than the tuple some earlier interaction left behind.
- disabled: every handler returns early — pointer down, pointer move and the
keys alike — so a slider locked from inside onValueChange stops emitting for
the rest of an in-flight drag, and the release still ends the drag but commits
nothing. Thumbs get tabIndex -1 and aria-disabled, the whole control dims.
Rendering & styling
- Semantic tokens only: track bg-muted, fill bg-primary, thumbs
border-primary + bg-background, bubble bg-primary/text-primary-foreground,
marks bg-primary-foreground/70 inside the selection and
bg-muted-foreground/40 outside, labels text-muted-foreground.
- Each thumb is role="slider" with aria-valuemin/aria-valuemax reflecting its
own constrained bounds, aria-valuenow, aria-valuetext (through
formatValue) and aria-label "<label> minimum" / "<label> maximum";
focus-visible ring on the thumb.
- Position transitions are disabled while dragging (the thumb must track the
finger exactly) and re-enabled for click/keyboard jumps, with
motion-reduce:transition-none so reduced motion snaps instantly while drag
still follows the pointer.
- touch-none on the hit-area so a touch drag never scrolls the page;
select-none on the root. Merge the consumer className via cn().
Customization levers
- Density: the hit-area height (h-5), track thickness (h-1.5) and thumb size
(size-5) are the three knobs — enlarge all three for touch-first UIs.
- Bubble policy: showTooltip renders a persistent bubble; gate it on
`dragging || focused` if you want it only during interaction.
- formatValue drives the bubble, the mark labels and aria-valuetext at once —
currency, units, dates, whatever; keep it pure.
- marks are decorative by default; add snapping-to-marks by rounding the
pointer value to the nearest mark before clamping.
- Colour: swap bg-primary on the fill for var(--chart-2) etc. when the slider
belongs to a chart's colour family; keep the thumb border in the same token.
- Vertical orientation: swap left/width for top/height and clientX for
clientY — the value math is axis-agnostic.Concepts
- Dual-thumb range — one control owns both ends of an interval, so the constraint "start ≤ end" lives inside the component instead of being re-derived by every consumer.
- Minimum gap constraint —
minGapturns each thumb's bound into a function of the other's position; the thumbs push rather than cross, which keeps degenerate empty ranges impossible. - Nearest-thumb track jump — pressing the track sends the closest thumb to that point and immediately starts dragging it, so a coarse click and a fine drag are the same gesture.
- Pointer capture over window listeners —
setPointerCapturekeeps every move/up event on the element even outside its box, which is why the component has noaddEventListenerto clean up and no way to leak a half-finished drag. - Change vs commit —
onValueChangestreams during the drag for live UI,onValueCommitfires once when the interaction settles; expensive work (refetch, URL sync) belongs on commit. The tuple that gets committed is scoped to one gesture (reset when it begins), so a commit never replays a range the value has already moved on from. - Step snapping with step-derived precision — values are rounded onto the grid using the decimal count of
stepitself, so a 0.1 step never produces0.30000000000000004.