Inputs & Formsv0.1.0
Range Slider
Tick dots and a thumb that bounces as it snaps to steps.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/range-slider.jsonManual
Install the dependencies
npm install clsx motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/range-slider/range-slider.tsx"use client"; import { useEffect, useRef, useState, type ComponentProps, type KeyboardEvent, type PointerEvent } from "react"; import { AnimatePresence, motion, useReducedMotion, useSpring, useTransform } from "motion/react"; import { cn } from "@/lib/utils"; import { duration, ease, spring } from "@/lib/motion"; export interface RangeSliderProps extends Omit<ComponentProps<"div">, "defaultValue" | "onChange" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> { /** Controlled value: one number, or two for a range. */ value?: number[]; /** Initial value when uncontrolled. @default [min] */ defaultValue?: number[]; /** Called on every change while dragging or using the keyboard. */ onValueChange?: (value: number[]) => void; /** Called once a change is finished: pointer released or key pressed. */ onValueCommit?: (value: number[]) => void; /** Lowest value. @default 0 */ min?: number; /** Highest value. @default 100 */ max?: number; /** Distance between values; the thumb snaps to each step. @default 1 */ step?: number; /** Show a dot at every step. @default true when there are 20 steps or fewer */ ticks?: boolean; /** Formats values for the tooltip and for screen readers. */ formatValue?: (value: number) => string; /** When the value tooltip shows. @default "active" */ tooltip?: "active" | "always" | "never"; /** Accessible names for the thumbs, e.g. ["Minimum price", "Maximum price"]. */ thumbLabels?: string[]; /** Disables pointer and keyboard input. @default false */ disabled?: boolean; } // A lightly damped spring, so the thumb overshoots a touch every time it snaps to a step. const SNAP = { stiffness: 520, damping: 19, mass: 0.7 }; const clamp = (n: number, lo: number, hi: number) => Math.min(Math.max(n, lo), hi); const decimals = (n: number) => (String(n).split(".")[1] ?? "").length; export function RangeSlider({ value, defaultValue, onValueChange, onValueCommit, min = 0, max = 100, step = 1, ticks, formatValue = (v) => String(v), tooltip = "active", thumbLabels, disabled = false, className, "aria-label": ariaLabel, ...props }: RangeSliderProps) { const [internal, setInternal] = useState(() => defaultValue ?? [min]); const values = (value ?? internal).map((v) => clamp(v, min, max)); const range = values.length > 1; const [dragging, setDragging] = useState<number | null>(null); const [focused, setFocused] = useState<number | null>(null); const trackRef = useRef<HTMLDivElement>(null); const thumbRefs = useRef<(HTMLDivElement | null)[]>([]); const reduceMotion = useReducedMotion(); const percent = (v: number) => ((v - min) / (max - min || 1)) * 100; const v0 = values[0]; const v1 = values[1] ?? values[0]; const p0 = useSpring(percent(v0), SNAP); const p1 = useSpring(percent(v1), SNAP); const x0 = useTransform(p0, (v) => `${v}%`); const x1 = useTransform(p1, (v) => `${v}%`); // The fill is clipped rather than scaled, so its rounded ends never stretch. const fillClip = useTransform(() => { const a = p0.get(); const b = range ? p1.get() : a; const lo = range ? Math.min(a, b) : 0; const hi = range ? Math.max(a, b) : a; return `inset(0 ${100 - hi}% 0 ${lo}% round 9999px)`; }); useEffect(() => { const t0 = ((v0 - min) / (max - min || 1)) * 100; const t1 = ((v1 - min) / (max - min || 1)) * 100; if (reduceMotion) { p0.jump(t0); p1.jump(t1); } else { p0.set(t0); p1.set(t1); } }, [v0, v1, min, max, reduceMotion, p0, p1]); const snap = (raw: number) => { const stepped = Math.round((raw - min) / step) * step + min; return clamp(Number(stepped.toFixed(decimals(step))), min, max); }; function update(index: number, raw: number) { let next = snap(raw); // Range thumbs can meet but never cross. if (range) next = index === 0 ? Math.min(next, values[1]) : Math.max(next, values[0]); if (next === values[index]) return values; const updated = values.map((v, i) => (i === index ? next : v)); if (value === undefined) setInternal(updated); onValueChange?.(updated); return updated; } function valueAt(clientX: number) { const rect = trackRef.current!.getBoundingClientRect(); return min + clamp((clientX - rect.left) / rect.width, 0, 1) * (max - min); } function onPointerDown(event: PointerEvent<HTMLDivElement>) { if (disabled || event.button !== 0) return; event.preventDefault(); const target = valueAt(event.clientX); let index = 0; if (range) { if (target <= values[0]) index = 0; else if (target >= values[1]) index = 1; else index = target - values[0] <= values[1] - target ? 0 : 1; } event.currentTarget.setPointerCapture(event.pointerId); thumbRefs.current[index]?.focus({ preventScroll: true }); setDragging(index); update(index, target); } function onPointerMove(event: PointerEvent<HTMLDivElement>) { if (dragging !== null) update(dragging, valueAt(event.clientX)); } function endDrag() { if (dragging === null) return; setDragging(null); onValueCommit?.(values); } function onThumbKey(index: number, event: KeyboardEvent<HTMLDivElement>) { const big = Math.max(step, snap(min + (max - min) / 10) - min); const current = values[index]; const targets: Record<string, number> = { ArrowRight: current + step, ArrowUp: current + step, ArrowLeft: current - step, ArrowDown: current - step, PageUp: current + big, PageDown: current - big, Home: min, End: max, }; if (!(event.key in targets) || disabled) return; event.preventDefault(); // Not `onValueCommit?.(update(...))`: optional calls skip evaluating their arguments. const next = update(index, targets[event.key]); onValueCommit?.(next); } const stepCount = Math.round((max - min) / step); const showTicks = ticks ?? stepCount <= 20; const lo = range ? values[0] : min; const hi = range ? values[1] : values[0]; return ( <div role={range ? "group" : undefined} aria-label={range ? ariaLabel : undefined} className={cn("relative w-full touch-none select-none", disabled && "pointer-events-none opacity-50", className)} {...props} > <div ref={trackRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={endDrag} onPointerCancel={endDrag} className="relative flex h-10 cursor-pointer items-center" > <div className="relative h-1.5 w-full rounded-full bg-muted"> <motion.div className="absolute inset-0 rounded-full bg-primary" style={{ clipPath: fillClip }} /> {showTicks ? Array.from({ length: stepCount + 1 }, (_, i) => { const tickValue = min + i * step; const inFill = tickValue >= lo && tickValue <= hi; return ( <span key={i} aria-hidden="true" style={{ left: `${percent(tickValue)}%` }} className={cn( "absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-150", inFill ? "bg-primary-foreground/80" : "bg-foreground/20", )} /> ); }) : null} </div> {values.slice(0, 2).map((thumbValue, index) => { const showTooltip = tooltip === "always" || (tooltip === "active" && (dragging === index || focused === index)); const label = thumbLabels?.[index] ?? (range ? (index === 0 ? "Minimum" : "Maximum") : ariaLabel); return ( // Full-width layer translated by a percentage: the thumb sits on its left edge. <motion.div key={index} className="pointer-events-none absolute inset-y-0 left-0 w-full" style={{ x: index === 0 ? x0 : x1 }} > <motion.div ref={(node) => { thumbRefs.current[index] = node; }} role="slider" tabIndex={disabled ? -1 : 0} aria-label={label} aria-valuemin={range && index === 1 ? values[0] : min} aria-valuemax={range && index === 0 ? values[1] : max} aria-valuenow={thumbValue} aria-valuetext={formatValue(thumbValue)} aria-orientation="horizontal" aria-disabled={disabled || undefined} onKeyDown={(event) => onThumbKey(index, event)} onFocus={() => setFocused(index)} onBlur={() => setFocused((f) => (f === index ? null : f))} animate={{ scale: dragging === index && !reduceMotion ? 1.15 : 1 }} transition={spring.snappy} className={cn( "pointer-events-auto absolute top-1/2 left-0 size-5 -translate-x-1/2 -translate-y-1/2 cursor-grab rounded-full border border-border bg-background outline-none", "shadow-[0_1px_2px_rgb(0_0_0/0.12),0_2px_8px_-2px_rgb(0_0_0/0.18)] focus-visible:ring-4 focus-visible:ring-ring/30", dragging === index && "cursor-grabbing", dragging === index || focused === index ? "z-20" : "z-10", )} > <AnimatePresence> {showTooltip ? ( <motion.span aria-hidden="true" initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 4, scale: 0.9 }} transition={{ duration: duration.quick, ease: ease.out }} className="absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 rounded-md bg-foreground px-2 py-1 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-md" > {formatValue(thumbValue)} </motion.span> ) : null} </AnimatePresence> </motion.div> </motion.div> ); })} </div> </div> ); }
Usage
import { RangeSlider } from "@/components/ui/range-slider/range-slider";
export function PriceFilter() {
const [range, setRange] = useState([200, 750]);
return (
<RangeSlider
aria-label="Price range"
thumbLabels={["Minimum price", "Maximum price"]}
value={range}
onValueChange={setRange}
max={1000}
step={50}
/>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
value | number[] | – | Controlled value: one number, or two for a range. |
defaultValue | number[] | [min] | Initial value when uncontrolled. |
onValueChange | (value: number[]) => void | – | Called on every change while dragging or using the keyboard. |
onValueCommit | (value: number[]) => void | – | Called once a change is finished: pointer released or key pressed. |
min | number | 0 | Lowest value. |
max | number | 100 | Highest value. |
step | number | 1 | Distance between values; the thumb snaps to each step. |
ticks | boolean | true when there are 20 steps or fewer | Show a dot at every step. |
formatValue | (value: number) => string | – | Formats values for the tooltip and for screen readers. |
tooltip | "active" | "always" | "never" | "active" | When the value tooltip shows. |
thumbLabels | string[] | – | Accessible names for the thumbs, e.g. ["Minimum price", "Maximum price"]. |
disabled | boolean | false | Disables pointer and keyboard input. |
Also accepts every native <div> attribute, including ref and className.
- Pass one value for a single thumb or two for a range.
- The thumb rides a full-width layer translated by a percentage, and the fill is a clip-path, so dragging never animates layout.
- Ticks appear automatically for 20 steps or fewer. Set ticks to force them on or off.
Accessibility
- Each thumb has role="slider" with aria-valuenow, aria-valuemin, aria-valuemax and an aria-valuetext built from formatValue.
- Arrow keys move one step, PageUp and PageDown move about a tenth of the range, and Home and End jump to the bounds.
- In range mode the thumbs are labelled ("Minimum", "Maximum" or thumbLabels) and each one's bounds follow the other, so they never cross.
- Clicking the track moves the nearest thumb and focuses it, so the keyboard picks up where the pointer left off.
- With reduced motion enabled, the thumb jumps to each step instead of springing.