{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"range-slider","type":"registry:component","title":"Range Slider","description":"Range slider with tick dots and a bouncy vertical-bar thumb that glides between snapped steps; drag and keyboard control, reduced-motion safe.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/range-slider.tsx","type":"registry:component","target":"@components/motion/range-slider.tsx","content":"\"use client\";\n\nimport {\n  motion,\n  useMotionTemplate,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n// Smooth glide for the thumb/fill — critically damped, no overshoot, so the\n// handle follows the pointer butterily and eases between snapped steps.\nconst SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const;\n// Bouncy grab feedback for the thumb scale only.\nconst SPRING_BOUNCY = { type: \"spring\", stiffness: 500, damping: 14, mass: 0.7 } as const;\n\nexport interface RangeSliderProps {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Render a tick dot at each step. */\n  showTicks?: boolean;\n  disabled?: boolean;\n  className?: string;\n  \"aria-label\"?: string;\n}\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nexport function RangeSlider({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  showTicks = true,\n  disabled = false,\n  className,\n  \"aria-label\": ariaLabel,\n}: RangeSliderProps) {\n  const reduce = useReducedMotion();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [internal, setInternal] = useState(defaultValue);\n  const [active, setActive] = useState(false);\n  const controlled = value !== undefined;\n  const current = clamp(controlled ? value : internal, min, max);\n  const percent = ((current - min) / (max - min)) * 100;\n\n  // Spring-smoothed position drives both the thumb and the fill.\n  const target = useMotionValue(percent);\n  useEffect(() => {\n    target.set(percent);\n  }, [percent, target]);\n  const smooth = useSpring(target, SPRING_GLIDE);\n  const pos = reduce ? target : smooth;\n  const left = useMotionTemplate`${pos}%`;\n  // Self-offset the thumb from 0% (flush left) to -100% (flush right) of its\n  // own width so it stays fully inside the track at both ends — no clip, no gap.\n  const thumbX = useTransform(pos, (p) => `${-p}%`);\n\n  const steps = Math.floor((max - min) / step);\n  const ticks =\n    showTicks && steps > 0 && steps <= 50\n      ? Array.from({ length: steps + 1 }, (_, i) => min + i * step)\n      : [];\n\n  const commit = useCallback(\n    (next: number) => {\n      const snapped = clamp(Math.round((next - min) / step) * step + min, min, max);\n      if (!controlled) setInternal(snapped);\n      onValueChange?.(snapped);\n    },\n    [controlled, onValueChange, min, max, step],\n  );\n\n  const valueFromX = useCallback(\n    (clientX: number) => {\n      const rect = trackRef.current?.getBoundingClientRect();\n      if (!rect) return current;\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      return min + ratio * (max - min);\n    },\n    [current, min, max],\n  );\n\n  const onPointerDown = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      event.currentTarget.setPointerCapture(event.pointerId);\n      setActive(true);\n      commit(valueFromX(event.clientX));\n    },\n    [disabled, commit, valueFromX],\n  );\n\n  const onPointerMove = useCallback(\n    (event: PointerEvent<HTMLDivElement>) => {\n      if (!active || disabled) return;\n      commit(valueFromX(event.clientX));\n    },\n    [active, disabled, commit, valueFromX],\n  );\n\n  const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {\n    event.currentTarget.releasePointerCapture?.(event.pointerId);\n    setActive(false);\n  }, []);\n\n  const onKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLDivElement>) => {\n      if (disabled) return;\n      const map: Record<string, number> = {\n        ArrowRight: current + step,\n        ArrowUp: current + step,\n        ArrowLeft: current - step,\n        ArrowDown: current - step,\n        Home: min,\n        End: max,\n      };\n      if (event.key in map) {\n        event.preventDefault();\n        commit(map[event.key]);\n      }\n    },\n    [disabled, current, step, min, max, commit],\n  );\n\n  return (\n    <div\n      ref={trackRef}\n      onPointerDown={onPointerDown}\n      onPointerMove={onPointerMove}\n      onPointerUp={endDrag}\n      onPointerCancel={endDrag}\n      className={cn(\n        \"relative flex h-10 w-full touch-none select-none items-center overflow-hidden rounded-lg bg-muted\",\n        disabled ? \"pointer-events-none opacity-50\" : \"cursor-grab active:cursor-grabbing\",\n        className,\n      )}\n    >\n      {/* fill — runs from the left edge to the thumb, consistent tone */}\n      <motion.div\n        className=\"absolute inset-y-0 left-0 bg-foreground/15\"\n        style={{ width: left }}\n      />\n\n      {/* ticks — slight inset so the end dots don't clip */}\n      <div className=\"pointer-events-none absolute inset-x-2 inset-y-0\">\n        {ticks.map((t) => {\n          const tp = ((t - min) / (max - min)) * 100;\n          return (\n            <span\n              key={t}\n              className=\"absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25\"\n              style={{ left: `${tp}%` }}\n            />\n          );\n        })}\n      </div>\n\n      {/* vertical bar thumb — contained at both ends via thumbX */}\n      <motion.div\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={ariaLabel}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={current}\n        aria-disabled={disabled || undefined}\n        onKeyDown={onKeyDown}\n        animate={reduce ? undefined : { scaleY: active ? 1.35 : 1 }}\n        transition={SPRING_BOUNCY}\n        className=\"absolute top-1/2 h-5 w-1.5 rounded-sm bg-foreground shadow-sm outline-none ring-foreground/30 focus-visible:ring-4\"\n        style={{ left, x: thumbX, y: \"-50%\" }}\n      />\n    </div>\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}