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

Install dependencies:

$ pnpm add motion

Add the utility function for class merging:

lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}

Copy the component code into your project:

components/ui/range-slider.tsx
tsx
"use client";
import React, {  useCallback,  useLayoutEffect,  useRef,  useState,} from "react";import { motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils";
export type RangeSliderProps = {  min?: number;  max?: number;  step?: number;  value?: [number, number];  defaultValue?: [number, number];  onValueChange?: (value: [number, number]) => void;  className?: string;  "aria-label"?: string;};
const SPRING = { type: "spring" as const, bounce: 0, duration: 0.28 };
function clamp(n: number, lo: number, hi: number) {  return Math.min(hi, Math.max(lo, n));}
function snap(n: number, step: number, min: number) {  return Math.round((n - min) / step) * step + min;}
function DigitReadout({  value,  className,}: {  value: string;  className?: string;}) {  const groupRef = useRef<HTMLSpanElement>(null);  const [animating, setAnimating] = useState(true);  const chars = value.split("");
  useLayoutEffect(() => {    const el = groupRef.current;    if (!el) return;    setAnimating(false);    void el.offsetHeight;    setAnimating(true);  }, [value]);
  return (    <span      ref={groupRef}      className={cn("t-digit-group", animating && "is-animating", className)}    >      {chars.map((ch, i) => {        const fromEnd = chars.length - 1 - i;        const stagger =          fromEnd === 1 ? "1" : fromEnd === 0 ? "2" : undefined;        return (          <span            key={`${i}-${ch}`}            className="t-digit"            {...(stagger ? { "data-stagger": stagger } : {})}          >            {ch}          </span>        );      })}    </span>  );}
export function RangeSlider({  min = 0,  max = 100,  step = 1,  value: valueProp,  defaultValue = [25, 75],  onValueChange,  className,  "aria-label": ariaLabel = "Range",}: RangeSliderProps) {  const reduce = useReducedMotion();  const trackRef = useRef<HTMLDivElement>(null);  const isControlled = valueProp !== undefined;  const [uncontrolled, setUncontrolled] = useState(defaultValue);  const value = isControlled ? valueProp : uncontrolled;  const [rubber, setRubber] = useState<[number, number] | null>(null);  const active = useRef<"lo" | "hi" | null>(null);
  const display = rubber ?? value;
  const commit = useCallback(    (next: [number, number]) => {      const lo = clamp(snap(Math.min(next[0], next[1]), step, min), min, max);      const hi = clamp(snap(Math.max(next[0], next[1]), step, min), min, max);      const sorted: [number, number] = [lo, hi];      if (!isControlled) setUncontrolled(sorted);      onValueChange?.(sorted);    },    [isControlled, max, min, onValueChange, step],  );
  const pct = (n: number) => ((n - min) / (max - min)) * 100;
  const valueFromClientX = (clientX: number) => {    const el = trackRef.current;    if (!el) return min;    const rect = el.getBoundingClientRect();    const raw = ((clientX - rect.left) / rect.width) * (max - min) + min;    const overshoot = 0.08 * (max - min);    return clamp(raw, min - overshoot, max + overshoot);  };
  const onPointerDown = (    which: "lo" | "hi",    e: React.PointerEvent,  ) => {    e.preventDefault();    active.current = which;    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);  };
  const onPointerMove = (e: React.PointerEvent) => {    if (!active.current) return;    const v = valueFromClientX(e.clientX);    const next: [number, number] =      active.current === "lo" ? [v, value[1]] : [value[0], v];    setRubber(next);  };
  const onPointerUp = (e: React.PointerEvent) => {    if (!active.current) return;    (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);    if (rubber) commit(rubber);    setRubber(null);    active.current = null;  };
  const nudge = (which: "lo" | "hi", dir: -1 | 1) => {    const next: [number, number] =      which === "lo"        ? [clamp(value[0] + dir * step, min, value[1]), value[1]]        : [value[0], clamp(value[1] + dir * step, value[0], max)];    commit(next);  };
  const loPct = pct(clamp(display[0], min, max));  const hiPct = pct(clamp(display[1], min, max));  const fillLeft = Math.min(loPct, hiPct);  const fillWidth = Math.abs(hiPct - loPct);
  const loLabel = String(Math.round(clamp(display[0], min, max)));  const hiLabel = String(Math.round(clamp(display[1], min, max)));
  return (    <div className={cn("w-full select-none", className)}>      <div className="mb-2 flex justify-between text-xs tabular-nums text-[var(--color-ink-muted)]">        <DigitReadout value={loLabel} />        <DigitReadout value={hiLabel} />      </div>      <div        ref={trackRef}        className="relative h-10 touch-none"        aria-label={ariaLabel}      >        <div className="absolute top-1/2 right-0 left-0 h-1.5 -translate-y-1/2 rounded-full bg-[var(--color-paper-2)]" />        <motion.div          className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[var(--color-ink)]"          animate={{ left: `${fillLeft}%`, width: `${fillWidth}%` }}          transition={reduce ? { duration: 0.1 } : SPRING}          initial={false}        />        {(["lo", "hi"] as const).map((which) => {          const v = which === "lo" ? display[0] : display[1];          const p = pct(clamp(v, min, max));          return (            <motion.button              key={which}              type="button"              aria-label={which === "lo" ? "Minimum" : "Maximum"}              aria-valuemin={min}              aria-valuemax={max}              aria-valuenow={Math.round(clamp(v, min, max))}              role="slider"              tabIndex={0}              onPointerDown={(e) => onPointerDown(which, e)}              onPointerMove={onPointerMove}              onPointerUp={onPointerUp}              onPointerCancel={onPointerUp}              onKeyDown={(e) => {                if (e.key === "ArrowLeft" || e.key === "ArrowDown") {                  e.preventDefault();                  nudge(which, -1);                } else if (e.key === "ArrowRight" || e.key === "ArrowUp") {                  e.preventDefault();                  nudge(which, 1);                }              }}              whileTap={reduce ? undefined : { scale: 0.96 }}              className={cn(                "absolute top-1/2 size-10 -translate-x-1/2 -translate-y-1/2 outline-none",                "flex items-center justify-center",                "focus-visible:ring-2 focus-visible:ring-[var(--color-focus)] rounded-full",              )}              style={{ left: `${p}%` }}              animate={{ left: `${p}%` }}              transition={                rubber                  ? { duration: 0 }                  : reduce                    ? { duration: 0.1 }                    : SPRING              }              initial={false}            >              <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)]" />            </motion.button>          );        })}      </div>    </div>  );}

Adjust import paths according to your folder structure.


Basic Usage

tsx
import { RangeSlider } from "@/components/ui/range-slider";
<RangeSlider  min={0}  max={120}  step={2}  value={range}  onValueChange={setRange}/>

Props

PropTypeRequiredDefaultDescription
minnumberNo0Range minimum.
maxnumberNo100Range maximum.
stepnumberNo1Snap increment.
value[number, number]NoControlled low/high pair.
onValueChange(value: [number, number]) => voidNoFires on commit (and keyboard).

Best Practices

  1. Keep thumb hit areas ≥40px even if the visual knob is smaller.
  2. Prefer even step values for SLA / price windows.
  3. Commit on pointer-up so mid-drag doesn’t thrash filters.