{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"Design-tool style scrubber slider with animated thumb and built-in label","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\nexport interface ScrubberProps {\n  /** Additional CSS classes */\n  className?: string;\n  /** Number of decimal places to display */\n  decimals?: number;\n  /** Default value for uncontrolled usage */\n  defaultValue?: number;\n  /** Label displayed on the left side of the track */\n  label?: string;\n  /** Maximum value */\n  max?: number;\n  /** Minimum value */\n  min?: number;\n  /** Called when value changes during interaction */\n  onValueChange?: (value: number) => void;\n  /** Step increment */\n  step?: number;\n  /** Number of tick marks (0 to hide) */\n  ticks?: number;\n  /** Controlled value */\n  value?: number;\n}\n\nconst clamp = (val: number, min: number, max: number) =>\n  Math.min(Math.max(val, min), max);\n\nconst roundToStep = (val: number, step: number, min: number) =>\n  Math.round((val - min) / step) * step + min;\n\nconst Scrubber = ({\n  label = \"Value\",\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  min = 0,\n  max = 1,\n  step = 0.01,\n  decimals = 2,\n  ticks = 9,\n  className,\n}: ScrubberProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const trackRef = useRef<HTMLDivElement>(null);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [isDragging, setIsDragging] = useState(false);\n  const [isHovering, setIsHovering] = useState(false);\n  const [isHoverDevice, setIsHoverDevice] = useState(false);\n\n  const isControlled = controlledValue !== undefined;\n  const value = isControlled ? controlledValue : internalValue;\n  const range = max - min;\n  const percentage = range > 0 ? ((value - min) / range) * 100 : 0;\n  const isActive = isDragging || (isHoverDevice && isHovering);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(hover: hover) and (pointer: fine)\");\n    setIsHoverDevice(mq.matches);\n    const onChange = (e: MediaQueryListEvent) => setIsHoverDevice(e.matches);\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  const setValue = useCallback(\n    (newValue: number) => {\n      const clamped = clamp(roundToStep(newValue, step, min), min, max);\n      if (!isControlled) {\n        setInternalValue(clamped);\n      }\n      onValueChange?.(clamped);\n    },\n    [step, min, max, isControlled, onValueChange]\n  );\n\n  const getValueFromPointer = useCallback(\n    (clientX: number) => {\n      const track = trackRef.current;\n      if (!track) {\n        return value;\n      }\n      const rect = track.getBoundingClientRect();\n      const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);\n      return min + ratio * range;\n    },\n    [min, range, value]\n  );\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      e.preventDefault();\n      trackRef.current?.setPointerCapture(e.pointerId);\n      setIsDragging(true);\n      setValue(getValueFromPointer(e.clientX));\n    },\n    [getValueFromPointer, setValue]\n  );\n\n  const handlePointerMove = useCallback(\n    (e: React.PointerEvent) => {\n      if (!isDragging) {\n        return;\n      }\n      setValue(getValueFromPointer(e.clientX));\n    },\n    [isDragging, getValueFromPointer, setValue]\n  );\n\n  const handlePointerUp = useCallback(() => {\n    setIsDragging(false);\n  }, []);\n\n  const handleKeyDown = useCallback(\n    (e: React.KeyboardEvent) => {\n      let next: number | undefined;\n      switch (e.key) {\n        case \"ArrowRight\":\n        case \"ArrowUp\":\n          next = value + step;\n          break;\n        case \"ArrowLeft\":\n        case \"ArrowDown\":\n          next = value - step;\n          break;\n        case \"Home\":\n          next = min;\n          break;\n        case \"End\":\n          next = max;\n          break;\n        default:\n          return;\n      }\n      e.preventDefault();\n      setValue(next);\n    },\n    [value, step, min, max, setValue]\n  );\n\n  const springConfig = shouldReduceMotion\n    ? { duration: 0 }\n    : { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\n\n  return (\n    <div className={cn(\"relative w-full select-none\", className)}>\n      <div\n        aria-label={label}\n        aria-valuemax={max}\n        aria-valuemin={min}\n        aria-valuenow={Number(value.toFixed(decimals))}\n        className=\"relative cursor-pointer overflow-hidden bg-muted outline-offset-2\"\n        onKeyDown={handleKeyDown}\n        onMouseEnter={() => setIsHovering(true)}\n        onMouseLeave={() => setIsHovering(false)}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        ref={trackRef}\n        role=\"slider\"\n        style={{\n          borderRadius: 8,\n          height: 36,\n          touchAction: \"none\",\n        }}\n        tabIndex={0}\n      >\n        {/* Fill indicator */}\n        <div\n          className=\"pointer-events-none absolute inset-y-0 left-0 bg-foreground/14\"\n          style={{\n            borderRadius: 8,\n            transition: isDragging\n              ? \"none\"\n              : \"width 150ms cubic-bezier(0.23, 1, 0.32, 1)\",\n            width: `${percentage}%`,\n          }}\n        />\n\n        {/* Tick marks */}\n        {ticks > 0 && (\n          <div className=\"pointer-events-none absolute inset-0\">\n            {Array.from({ length: ticks }, (_, i) => {\n              const pos = ((i + 1) / (ticks + 1)) * 100;\n              return (\n                <div\n                  className=\"absolute top-1/2 bg-foreground/25\"\n                  key={pos}\n                  style={{\n                    borderRadius: 999,\n                    height: 6,\n                    left: `${pos}%`,\n                    transform: \"translateX(-50%) translateY(-50%)\",\n                    width: 1,\n                  }}\n                />\n              );\n            })}\n          </div>\n        )}\n\n        {/* Scrub bar (capsule thumb) */}\n        <div\n          className=\"pointer-events-none absolute\"\n          style={{\n            left: `${percentage}%`,\n            marginLeft: -5,\n            top: \"50%\",\n            transform: \"translateX(-50%) translateY(-50%)\",\n            transition: isDragging\n              ? \"none\"\n              : \"left 150ms cubic-bezier(0.23, 1, 0.32, 1)\",\n            zIndex: 3,\n          }}\n        >\n          <motion.div\n            animate={{\n              opacity: isActive ? 0.8 : 0.15,\n              scaleX: isActive ? 1 : 0.7,\n              scaleY: isActive ? 1 : 0.7,\n            }}\n            className=\"bg-foreground/90\"\n            style={{\n              borderRadius: 999,\n              height: 22,\n              width: 4,\n            }}\n            transition={springConfig}\n          />\n        </div>\n\n        {/* Label */}\n        <div\n          className=\"pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 whitespace-nowrap text-foreground\"\n          style={{\n            fontSize: 13,\n            fontWeight: 500,\n            zIndex: 4,\n          }}\n        >\n          {label}\n        </div>\n\n        {/* Value display */}\n        <div\n          className=\"pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-foreground\"\n          style={{\n            fontFamily: \"ui-monospace, monospace\",\n            fontSize: 13,\n            fontVariantNumeric: \"tabular-nums\",\n            fontWeight: 500,\n            zIndex: 4,\n          }}\n        >\n          {value.toFixed(decimals)}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default Scrubber;\n","path":"index.tsx","target":"components/smoothui/scrubber/index.tsx","type":"registry:ui"}],"name":"scrubber","registryDependencies":[],"title":"Scrubber","type":"registry:ui"}