Overview
Dual thumbs for filter windows. Pointer capture tracks 1:1 while dragging, rubber-bands past ends, then springs back on release. Keyboard arrows nudge by step. Live values use tabular-nums.
Installation
Use the CLI to install the component automatically:
$ pnpm dlx shadcn@latest add https://ui.sanjid.in/r/range-slider.jsonInstall dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
1import { ClassValue, clsx } from "clsx";2import { twMerge } from "tailwind-merge";3 4export function cn(...inputs: ClassValue[]) {5 return twMerge(clsx(inputs));6}
Copy the component code into your project:
tsx
1"use client";2 3import React, {4 useCallback,5 useLayoutEffect,6 useRef,7 useState,8} from "react";9import { motion, useReducedMotion } from "motion/react";10import { cn } from "@/lib/utils";11 12export type RangeSliderProps = {13 min?: number;14 max?: number;15 step?: number;16 value?: [number, number];17 defaultValue?: [number, number];18 onValueChange?: (value: [number, number]) => void;19 className?: string;20 "aria-label"?: string;21};22 23const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };24 25function clamp(n: number, lo: number, hi: number) {26 return Math.min(hi, Math.max(lo, n));27}28 29function snap(n: number, step: number, min: number) {30 return Math.round((n - min) / step) * step + min;31}32 33function DigitReadout({34 value,35 className,36}: {37 value: string;38 className?: string;39}) {40 const groupRef = useRef<HTMLSpanElement>(null);41 const [animating, setAnimating] = useState(true);42 const chars = value.split("");43 44 useLayoutEffect(() => {45 const el = groupRef.current;46 if (!el) return;47 setAnimating(false);48 void el.offsetHeight;49 setAnimating(true);50 }, [value]);51 52 return (53 <span54 ref={groupRef}55 className={cn("t-digit-group", animating && "is-animating", className)}56 >57 {chars.map((ch, i) => {58 const fromEnd = chars.length - 1 - i;59 const stagger =60 fromEnd === 1 ? "1" : fromEnd === 0 ? "2" : undefined;61 return (62 <span63 key={`${i}-${ch}`}64 className="t-digit"65 {...(stagger ? { "data-stagger": stagger } : {})}66 >67 {ch}68 </span>69 );70 })}71 </span>72 );73}74 75export function RangeSlider({76 min = 0,77 max = 100,78 step = 1,79 value: valueProp,80 defaultValue = [25, 75],81 onValueChange,82 className,83 "aria-label": ariaLabel = "Range",84}: RangeSliderProps) {85 const reduce = useReducedMotion();86 const trackRef = useRef<HTMLDivElement>(null);87 const isControlled = valueProp !== undefined;88 const [uncontrolled, setUncontrolled] = useState(defaultValue);89 const value = isControlled ? valueProp : uncontrolled;90 const [rubber, setRubber] = useState<[number, number] | null>(null);91 const active = useRef<"lo" | "hi" | null>(null);92 93 const display = rubber ?? value;94 95 const commit = useCallback(96 (next: [number, number]) => {97 const lo = clamp(snap(Math.min(next[0], next[1]), step, min), min, max);98 const hi = clamp(snap(Math.max(next[0], next[1]), step, min), min, max);99 const sorted: [number, number] = [lo, hi];100 if (!isControlled) setUncontrolled(sorted);101 onValueChange?.(sorted);102 },103 [isControlled, max, min, onValueChange, step],104 );105 106 const pct = (n: number) => ((n - min) / (max - min)) * 100;107 108 const valueFromClientX = (clientX: number) => {109 const el = trackRef.current;110 if (!el) return min;111 const rect = el.getBoundingClientRect();112 const raw = ((clientX - rect.left) / rect.width) * (max - min) + min;113 const overshoot = 0.08 * (max - min);114 return clamp(raw, min - overshoot, max + overshoot);115 };116 117 const onPointerDown = (118 which: "lo" | "hi",119 e: React.PointerEvent,120 ) => {121 e.preventDefault();122 active.current = which;123 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);124 };125 126 const onPointerMove = (e: React.PointerEvent) => {127 if (!active.current) return;128 const v = valueFromClientX(e.clientX);129 const next: [number, number] =130 active.current === "lo" ? [v, value[1]] : [value[0], v];131 setRubber(next);132 };133 134 const onPointerUp = (e: React.PointerEvent) => {135 if (!active.current) return;136 (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);137 if (rubber) commit(rubber);138 setRubber(null);139 active.current = null;140 };141 142 const nudge = (which: "lo" | "hi", dir: -1 | 1) => {143 const next: [number, number] =144 which === "lo"145 ? [clamp(value[0] + dir * step, min, value[1]), value[1]]146 : [value[0], clamp(value[1] + dir * step, value[0], max)];147 commit(next);148 };149 150 const loPct = pct(clamp(display[0], min, max));151 const hiPct = pct(clamp(display[1], min, max));152 const fillLeft = Math.min(loPct, hiPct);153 const fillWidth = Math.abs(hiPct - loPct);154 155 const loLabel = String(Math.round(clamp(display[0], min, max)));156 const hiLabel = String(Math.round(clamp(display[1], min, max)));157 158 return (159 <div className={cn("w-full select-none", className)}>160 <div className="mb-2 flex justify-between text-xs tabular-nums text-[var(--color-ink-muted)]">161 <DigitReadout value={loLabel} />162 <DigitReadout value={hiLabel} />163 </div>164 <div165 ref={trackRef}166 className="relative h-10 touch-none"167 aria-label={ariaLabel}168 >169 <div className="absolute top-1/2 right-0 left-0 h-1.5 -translate-y-1/2 rounded-full bg-[var(--color-paper-2)]" />170 <motion.div171 className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[var(--color-ink)]"172 animate={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}173 transition={reduce ? { duration: 0.1 } : SPRING}174 initial={false}175 />176 {(["lo", "hi"] as const).map((which) => {177 const v = which === "lo" ? display[0] : display[1];178 const p = pct(clamp(v, min, max));179 return (180 <motion.button181 key={which}182 type="button"183 aria-label={which === "lo" ? "Minimum" : "Maximum"}184 aria-valuemin={min}185 aria-valuemax={max}186 aria-valuenow={Math.round(clamp(v, min, max))}187 role="slider"188 tabIndex={0}189 onPointerDown={(e) => onPointerDown(which, e)}190 onPointerMove={onPointerMove}191 onPointerUp={onPointerUp}192 onPointerCancel={onPointerUp}193 onKeyDown={(e) => {194 if (e.key === "ArrowLeft" || e.key === "ArrowDown") {195 e.preventDefault();196 nudge(which, -1);197 } else if (e.key === "ArrowRight" || e.key === "ArrowUp") {198 e.preventDefault();199 nudge(which, 1);200 }201 }}202 whileTap={reduce ? undefined : { scale: 0.96 }}203 className={cn(204 "absolute top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 outline-none",205 "flex items-center justify-center",206 "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] rounded-full",207 )}208 style={{ left: `${p}%` }}209 animate={{ left: `${p}%` }}210 transition={211 rubber212 ? { duration: 0 }213 : reduce214 ? { duration: 0.1 }215 : SPRING216 }217 initial={false}218 >219 <span className="size-5 rounded-full bg-[var(--color-paper)] shadow-[0_1px_4px_rgba(0,0,0,0.2)] ring-1 ring-[var(--color-rule)]" />220 </motion.button>221 );222 })}223 </div>224 </div>225 );226}
Adjust import paths according to your folder structure.
Basic Usage
tsx
1import { RangeSlider } from "@/components/ui/range-slider";2 3<RangeSlider4 min={0}5 max={120}6 step={2}7 value={range}8 onValueChange={setRange}9/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| min | number | No | 0 | Range minimum. |
| max | number | No | 100 | Range maximum. |
| step | number | No | 1 | Snap increment. |
| value | [number, number] | No | — | Controlled low/high pair. |
| onValueChange | (value: [number, number]) => void | No | — | Fires on commit (and keyboard). |
Best Practices
- Keep thumb hit areas ≥40px even if the visual knob is smaller.
- Prefer even
stepvalues for SLA / price windows. - Commit on pointer-up so mid-drag doesn’t thrash filters.