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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

const THUMBS = [0, 1] as const
type ThumbIndex = (typeof THUMBS)[number]

function clamp(n: number, low: number, high: number) {
  return Math.min(Math.max(n, low), high)
}

/** step's decimal places decide the rounding precision, so 0.1 steps never leave floating-point tails. */
function decimalsOf(step: number) {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/range-slider.json

Prompt

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 constraintminGap turns 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 listenerssetPointerCapture keeps every move/up event on the element even outside its box, which is why the component has no addEventListener to clean up and no way to leak a half-finished drag.
  • Change vs commitonValueChange streams during the drag for live UI, onValueCommit fires 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 step itself, so a 0.1 step never produces 0.30000000000000004.

On This Page