"use client"; import { motion, useMotionTemplate, useMotionValue, useReducedMotion, useSpring, useTransform, } from "motion/react"; import { type KeyboardEvent, type PointerEvent, useCallback, useEffect, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; // Smooth glide for the thumb/fill — critically damped, no overshoot, so the // handle follows the pointer butterily and eases between snapped steps. const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const; // Bouncy grab feedback for the thumb scale only. const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const; export interface RangeSliderProps { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; min?: number; max?: number; step?: number; /** Render a tick dot at each step. */ showTicks?: boolean; disabled?: boolean; className?: string; "aria-label"?: string; } const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v)); export function RangeSlider({ value, defaultValue = 0, onValueChange, min = 0, max = 100, step = 1, showTicks = true, disabled = false, className, "aria-label": ariaLabel, }: RangeSliderProps) { const reduce = useReducedMotion(); const trackRef = useRef(null); const [internal, setInternal] = useState(defaultValue); const [active, setActive] = useState(false); const controlled = value !== undefined; const current = clamp(controlled ? value : internal, min, max); const percent = ((current - min) / (max - min)) * 100; // Spring-smoothed position drives both the thumb and the fill. const target = useMotionValue(percent); useEffect(() => { target.set(percent); }, [percent, target]); const smooth = useSpring(target, SPRING_GLIDE); const pos = reduce ? target : smooth; const left = useMotionTemplate`${pos}%`; // Self-offset the thumb from 0% (flush left) to -100% (flush right) of its // own width so it stays fully inside the track at both ends — no clip, no gap. const thumbX = useTransform(pos, (p) => `${-p}%`); const steps = Math.floor((max - min) / step); const ticks = showTicks && steps > 0 && steps <= 50 ? Array.from({ length: steps + 1 }, (_, i) => min + i * step) : []; const commit = useCallback( (next: number) => { const snapped = clamp(Math.round((next - min) / step) * step + min, min, max); if (!controlled) setInternal(snapped); onValueChange?.(snapped); }, [controlled, onValueChange, min, max, step], ); const valueFromX = useCallback( (clientX: number) => { const rect = trackRef.current?.getBoundingClientRect(); if (!rect) return current; const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); return min + ratio * (max - min); }, [current, min, max], ); const onPointerDown = useCallback( (event: PointerEvent) => { if (disabled) return; event.currentTarget.setPointerCapture(event.pointerId); setActive(true); commit(valueFromX(event.clientX)); }, [disabled, commit, valueFromX], ); const onPointerMove = useCallback( (event: PointerEvent) => { if (!active || disabled) return; commit(valueFromX(event.clientX)); }, [active, disabled, commit, valueFromX], ); const endDrag = useCallback((event: PointerEvent) => { event.currentTarget.releasePointerCapture?.(event.pointerId); setActive(false); }, []); const onKeyDown = useCallback( (event: KeyboardEvent) => { if (disabled) return; const map: Record = { ArrowRight: current + step, ArrowUp: current + step, ArrowLeft: current - step, ArrowDown: current - step, Home: min, End: max, }; if (event.key in map) { event.preventDefault(); commit(map[event.key]); } }, [disabled, current, step, min, max, commit], ); return (
{/* fill — runs from the left edge to the thumb, consistent tone */} {/* ticks — slight inset so the end dots don't clip */}
{ticks.map((t) => { const tp = ((t - min) / (max - min)) * 100; return ( ); })}
{/* vertical bar thumb — contained at both ends via thumbX */}
); }