{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "elastic-slider", "title": "Elastic Slider", "author": "tusharvarshney ", "description": "Slider with elastic rubber-band drag and magnetic snap feedback.", "dependencies": [ "motion" ], "registryDependencies": [ "https://tusharvarshney.com/r/use-controllable-state.json" ], "files": [ { "path": "src/registry/components/elastic-slider/elastic-slider.tsx", "content": "\"use client\"\n\nimport {\n animate,\n motion,\n useMotionValue,\n useReducedMotion,\n useTransform,\n} from \"motion/react\"\nimport {\n useCallback,\n useEffect,\n useLayoutEffect,\n useRef,\n useState,\n} from \"react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/registry/hooks/use-controllable-state\"\n\n// Drag detection & rubber band\nconst CLICK_THRESHOLD = 3\nconst DEAD_ZONE = 32\nconst MAX_CURSOR_RANGE = 200\nconst MAX_STRETCH = 8\n\n// Layout offsets used by the \"handle dodges label/value\" calculation.\nconst HANDLE_BUFFER = 8\nconst LABEL_OFFSET = 12 + 4\nconst VALUE_OFFSET = 12 - 8\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.max(lo, Math.min(hi, v))\n}\n\nfunction decimalsForStep(step: number): number {\n const s = step.toString()\n const dot = s.indexOf(\".\")\n return dot === -1 ? 0 : s.length - dot - 1\n}\n\nfunction roundValue(val: number, step: number): number {\n const raw = Math.round(val / step) * step\n return parseFloat(raw.toFixed(decimalsForStep(step)))\n}\n\n// Magnetic snap to the nearest decile when within 3.125% of it.\nfunction snapToDecile(rawValue: number, min: number, max: number): number {\n const normalized = (rawValue - min) / (max - min)\n const nearest = Math.round(normalized * 10) / 10\n if (Math.abs(normalized - nearest) <= 0.03125) {\n return min + nearest * (max - min)\n }\n return rawValue\n}\n\nexport type ElasticSliderProps = {\n /** Label shown inside the track. */\n label: string\n\n /** Controlled value. Use together with `onValueChange` */\n value?: number\n /** Initial value for uncontrolled mode. Falls back to `min` */\n defaultValue?: number\n /** Called with the new value on drag, click, or key press. */\n onValueChange?: (value: number) => void\n\n /**\n * Minimum value.\n * @defaultValue 0 */\n min?: number\n /**\n * Maximum value.\n * @defaultValue 1 */\n max?: number\n /**\n * Smallest increment.\n * @defaultValue 0.01 */\n step?: number\n /** Format the displayed value. Defaults to `value.toFixed(...)` based on `step` */\n formatValue?: (value: number) => string\n\n className?: string\n /** Accessible name. Falls back to `label` */\n \"aria-label\"?: string\n}\n\nexport function ElasticSlider({\n label,\n\n value: valueProp,\n defaultValue,\n onValueChange,\n\n min = 0,\n max = 1,\n step = 0.01,\n formatValue,\n\n className,\n \"aria-label\": ariaLabel,\n}: ElasticSliderProps) {\n const [value = min, setValue] = useControllableState({\n prop: valueProp,\n defaultProp: defaultValue ?? min,\n onChange: onValueChange,\n })\n\n const shouldReduceMotion = useReducedMotion()\n\n const wrapperRef = useRef(null)\n const trackRef = useRef(null)\n const labelRef = useRef(null)\n const valueRef = useRef(null)\n\n const [isInteracting, setIsInteracting] = useState(false)\n const [isDragging, setIsDragging] = useState(false)\n const [isHovered, setIsHovered] = useState(false)\n /** Ring only for Tab focus or keyboard value nudges, not pointer press/drag. */\n const [keyboardFocusRing, setKeyboardFocusRing] = useState(false)\n\n // Pointer session state — mutable, does not trigger re-renders.\n const pointerDownPos = useRef<{ x: number; y: number } | null>(null)\n const pendingPointerFocusRef = useRef(false)\n const isClickRef = useRef(true)\n const animRef = useRef | null>(null)\n const wrapperRectRef = useRef(null)\n const scaleRef = useRef(1)\n\n const percentage = ((value - min) / (max - min)) * 100\n const isActive = isInteracting || isHovered\n const displayValue = formatValue\n ? formatValue(value)\n : value.toFixed(decimalsForStep(step))\n\n // Fill + handle driven by a single motion value for imperative updates.\n const fillPercent = useMotionValue(percentage)\n const fillWidth = useTransform(fillPercent, (pct) => `${pct}%`)\n const handleLeft = useTransform(\n fillPercent,\n (pct) => `max(4px, calc(${pct}% - 8px))`\n )\n\n // Rubber band: widens the track and pulls it left when dragged past bounds.\n const rubberStretch = useMotionValue(0)\n const rubberWidth = useTransform(\n rubberStretch,\n (s) => `calc(100% + ${Math.abs(s)}px)`\n )\n const rubberX = useTransform(rubberStretch, (s) => (s < 0 ? s : 0))\n\n // Sync from props when not interacting and no spring is in flight.\n useEffect(() => {\n if (!isInteracting && !animRef.current) {\n fillPercent.jump(percentage)\n }\n }, [percentage, isInteracting, fillPercent])\n\n const positionToValue = useCallback(\n (clientX: number) => {\n const rect = wrapperRectRef.current\n if (!rect) return min\n\n const sceneX = (clientX - rect.left) / scaleRef.current\n const nativeWidth = wrapperRef.current?.offsetWidth ?? rect.width\n const percent = clamp(sceneX / nativeWidth, 0, 1)\n\n return clamp(min + percent * (max - min), min, max)\n },\n [min, max]\n )\n\n const percentFromValue = useCallback(\n (v: number) => ((v - min) / (max - min)) * 100,\n [min, max]\n )\n\n // Animate fill to a target percent, or jump instantly when the user prefers\n // reduced motion. Position still updates — only the spring is skipped.\n const animateFillTo = useCallback(\n (targetPercent: number) => {\n animRef.current?.stop()\n\n if (shouldReduceMotion) {\n fillPercent.jump(targetPercent)\n animRef.current = null\n return\n }\n\n animRef.current = animate(fillPercent, targetPercent, {\n type: \"spring\",\n stiffness: 300,\n damping: 25,\n mass: 0.8,\n onComplete: () => {\n animRef.current = null\n },\n })\n },\n [fillPercent, shouldReduceMotion]\n )\n\n const computeRubberStretch = useCallback((clientX: number, sign: number) => {\n const rect = wrapperRectRef.current\n if (!rect) return 0\n\n const distancePast = sign < 0 ? rect.left - clientX : clientX - rect.right\n const overflow = Math.max(0, distancePast - DEAD_ZONE)\n\n return (\n sign * MAX_STRETCH * Math.sqrt(Math.min(overflow / MAX_CURSOR_RANGE, 1))\n )\n }, [])\n\n const handlePointerDown = useCallback((e: React.PointerEvent) => {\n e.preventDefault()\n ;(e.target as HTMLElement).setPointerCapture(e.pointerId)\n\n pointerDownPos.current = { x: e.clientX, y: e.clientY }\n\n isClickRef.current = true\n\n setIsInteracting(true)\n\n pendingPointerFocusRef.current = true\n setKeyboardFocusRing(false)\n\n // Pointer interactions should move focus to the slider so subsequent\n // keyboard input is received and focus styles match the active state.\n trackRef.current?.focus({ preventScroll: true })\n requestAnimationFrame(() => {\n pendingPointerFocusRef.current = false\n })\n\n // Snapshot the wrapper rect so later math is immune to layout shifts.\n const wrapper = wrapperRef.current\n if (wrapper) {\n const rect = wrapper.getBoundingClientRect()\n wrapperRectRef.current = rect\n scaleRef.current = rect.width / wrapper.offsetWidth\n }\n }, [])\n\n const handlePointerMove = useCallback(\n (e: React.PointerEvent) => {\n if (!isInteracting || !pointerDownPos.current) return\n\n const dx = e.clientX - pointerDownPos.current.x\n const dy = e.clientY - pointerDownPos.current.y\n\n if (isClickRef.current && Math.hypot(dx, dy) > CLICK_THRESHOLD) {\n isClickRef.current = false\n setIsDragging(true)\n }\n\n if (isClickRef.current) return\n\n const rect = wrapperRectRef.current\n if (rect && !shouldReduceMotion) {\n if (e.clientX < rect.left) {\n rubberStretch.jump(computeRubberStretch(e.clientX, -1))\n } else if (e.clientX > rect.right) {\n rubberStretch.jump(computeRubberStretch(e.clientX, 1))\n } else {\n rubberStretch.jump(0)\n }\n }\n\n const newValue = positionToValue(e.clientX)\n animRef.current?.stop()\n animRef.current = null\n fillPercent.jump(percentFromValue(newValue))\n setValue(roundValue(newValue, step))\n },\n [\n isInteracting,\n positionToValue,\n percentFromValue,\n setValue,\n step,\n fillPercent,\n rubberStretch,\n computeRubberStretch,\n shouldReduceMotion,\n ]\n )\n\n const handlePointerUp = useCallback(\n (e: React.PointerEvent) => {\n if (!isInteracting) return\n\n if (isClickRef.current) {\n // Coarse sliders (≤10 positions) snap to the nearest step;\n // continuous sliders keep the decile-magnetic behavior.\n const rawValue = positionToValue(e.clientX)\n const discreteSteps = (max - min) / step\n const snapped =\n discreteSteps <= 10\n ? clamp(min + Math.round((rawValue - min) / step) * step, min, max)\n : snapToDecile(rawValue, min, max)\n\n animateFillTo(percentFromValue(snapped))\n setValue(roundValue(snapped, step))\n }\n\n if (!shouldReduceMotion && rubberStretch.get() !== 0) {\n animate(rubberStretch, 0, {\n type: \"spring\",\n visualDuration: 0.35,\n bounce: 0.15,\n })\n }\n\n setIsInteracting(false)\n setIsDragging(false)\n pointerDownPos.current = null\n },\n [\n isInteracting,\n positionToValue,\n percentFromValue,\n setValue,\n min,\n max,\n step,\n animateFillTo,\n rubberStretch,\n shouldReduceMotion,\n ]\n )\n\n const handleKeyDown = useCallback(\n (e: React.KeyboardEvent) => {\n // Shift + Arrow is a Figma-style fast nudge: jumps by 10x the step,\n // independent of the WAI-ARIA Page step (which scales with range).\n const arrowStep = e.shiftKey ? step * 10 : step\n\n let next: number | null = null\n\n switch (e.key) {\n case \"ArrowRight\":\n case \"ArrowUp\":\n next = value + arrowStep\n break\n\n case \"ArrowLeft\":\n case \"ArrowDown\":\n next = value - arrowStep\n break\n\n case \"Home\":\n next = min\n break\n\n case \"End\":\n next = max\n break\n\n default:\n return\n }\n\n e.preventDefault()\n\n setKeyboardFocusRing(true)\n\n const snapped = roundValue(clamp(next, min, max), step)\n animateFillTo(percentFromValue(snapped))\n setValue(snapped)\n },\n [value, min, max, step, animateFillTo, percentFromValue, setValue]\n )\n\n const handleTrackFocus = useCallback(() => {\n if (!pendingPointerFocusRef.current) {\n setKeyboardFocusRing(true)\n }\n }, [])\n\n const handleTrackBlur = useCallback(() => {\n setKeyboardFocusRing(false)\n }, [])\n\n // Measure label + value to derive \"dodge\" thresholds so the handle fades\n // when it would overlap either text.\n const [dodge, setDodge] = useState({ left: 38, right: 72 })\n\n useLayoutEffect(() => {\n const wrapper = wrapperRef.current\n if (!wrapper) return\n\n const measure = () => {\n const trackWidth = wrapper.offsetWidth\n if (trackWidth <= 0) return\n\n const labelEl = labelRef.current\n const valueEl = valueRef.current\n\n const left = labelEl\n ? ((LABEL_OFFSET + labelEl.offsetWidth + HANDLE_BUFFER) / trackWidth) *\n 100\n : 38\n\n const right = valueEl\n ? ((trackWidth - VALUE_OFFSET - valueEl.offsetWidth - HANDLE_BUFFER) /\n trackWidth) *\n 100\n : 72\n\n setDodge((prev) => {\n return prev.left === left && prev.right === right\n ? prev\n : { left, right }\n })\n }\n\n measure()\n\n const observer = new ResizeObserver(measure)\n observer.observe(wrapper)\n\n if (labelRef.current) observer.observe(labelRef.current)\n if (valueRef.current) observer.observe(valueRef.current)\n\n return () => observer.disconnect()\n }, [label, displayValue])\n\n const valueDodge = percentage < dodge.left || percentage > dodge.right\n const handleOpacity = !isActive\n ? 0\n : valueDodge\n ? 0.1\n : isDragging\n ? 0.8\n : 0.5\n\n const discreteSteps = (max - min) / step\n const hashMarkCount = discreteSteps <= 10 ? discreteSteps - 1 : 9\n\n const hashMarkPct = (i: number) => {\n return discreteSteps <= 10\n ? (((i + 1) * step) / (max - min)) * 100\n : (i + 1) * 10\n }\n\n return (\n \n setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n \n {Array.from({ length: hashMarkCount }, (_, i) => (\n \n ))}\n \n\n \n\n \n\n \n {label}\n \n\n \n {displayValue}\n \n \n \n )\n}\n", "type": "registry:component", "target": "@components/elastic-slider.tsx" } ], "docs": "https://tusharvarshney.com/components/elastic-slider", "type": "registry:component" }