{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"iOS-style exposure slider with draggable ticker and progress ring","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { useCallback, useRef } from \"react\";\n\nexport interface ExposureSliderProps {\n  /** Accent color for the active notch and progress ring (CSS color value) */\n  accentColor?: string;\n  /** Additional CSS classes */\n  className?: string;\n  /** Initial value */\n  defaultValue?: number;\n  /** Maximum value */\n  max?: number;\n  /** Minimum value */\n  min?: number;\n  /** Callback fired when the value changes */\n  onChange?: (value: number) => void;\n  /** Show the circular progress indicator with the current value */\n  showIndicator?: boolean;\n  /** Step size between values */\n  step?: number;\n}\n\nconst NOTCH_WIDTH = 13; // px per notch (3px notch + 10px gap)\nconst SPRING_CONFIG = { damping: 30, mass: 0.5, stiffness: 300 };\n\nconst DEFAULT_ACCENT = \"var(--color-brand, oklch(0.65 0.25 12))\";\n\nconst ExposureSlider = ({\n  min = -20,\n  max = 20,\n  step = 1,\n  defaultValue = 0,\n  onChange,\n  showIndicator = true,\n  accentColor,\n  className,\n}: ExposureSliderProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const containerRef = useRef<HTMLDivElement>(null);\n  const isDragging = useRef(false);\n\n  const count = Math.floor((max - min) / step) + 1;\n  const centerIndex = Math.floor((defaultValue - min) / step);\n\n  // Raw drag offset and spring-smoothed version\n  const rawX = useMotionValue(0);\n  const x = shouldReduceMotion ? rawX : useSpring(rawX, SPRING_CONFIG);\n\n  // Current value derived from offset\n  const currentValue = useTransform(x, (latest) => {\n    const indexOffset = Math.round(-latest / NOTCH_WIDTH);\n    const val = Math.max(\n      min,\n      Math.min(max, (centerIndex + indexOffset) * step + min)\n    );\n    return val;\n  });\n\n  // Track value for circle and display\n  const displayValue = useTransform(currentValue, (v) => Math.round(v));\n  const normalizedValue = useTransform(currentValue, [min, max], [-1, 1]);\n\n  const snapToNearest = useCallback(() => {\n    const current = rawX.get();\n    const snapped = Math.round(current / NOTCH_WIDTH) * NOTCH_WIDTH;\n    rawX.set(snapped);\n  }, [rawX]);\n\n  const handlePointerDown = useCallback(\n    (e: React.PointerEvent) => {\n      isDragging.current = true;\n      const startX = e.clientX;\n      const startOffset = rawX.get();\n\n      const handleMove = (moveEvent: PointerEvent) => {\n        const delta = moveEvent.clientX - startX;\n        const newOffset = startOffset + delta;\n        // Clamp to bounds\n        const maxOffset = (count - 1 - centerIndex) * NOTCH_WIDTH;\n        const minOffset = -centerIndex * NOTCH_WIDTH;\n        rawX.set(Math.max(-maxOffset, Math.min(-minOffset, newOffset)));\n\n        // Fire onChange\n        const indexOffset = Math.round(-rawX.get() / NOTCH_WIDTH);\n        const val = Math.max(\n          min,\n          Math.min(max, (centerIndex + indexOffset) * step + min)\n        );\n        onChange?.(Math.round(val));\n      };\n\n      const handleUp = () => {\n        isDragging.current = false;\n        snapToNearest();\n        // Fire final onChange\n        const indexOffset = Math.round(-rawX.get() / NOTCH_WIDTH);\n        const val = Math.max(\n          min,\n          Math.min(max, (centerIndex + indexOffset) * step + min)\n        );\n        onChange?.(Math.round(val));\n        window.removeEventListener(\"pointermove\", handleMove);\n        window.removeEventListener(\"pointerup\", handleUp);\n      };\n\n      window.addEventListener(\"pointermove\", handleMove);\n      window.addEventListener(\"pointerup\", handleUp);\n    },\n    [rawX, centerIndex, count, min, max, step, onChange, snapToNearest]\n  );\n\n  const items = Array.from({ length: count }, (_, i) => i);\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full max-w-[500px] flex-col items-center gap-6 text-foreground\",\n        className\n      )}\n      style={\n        { \"--es-accent\": accentColor ?? DEFAULT_ACCENT } as React.CSSProperties\n      }\n    >\n      {/* Progress circle */}\n      {showIndicator ? (\n        <ProgressCircle\n          displayValue={displayValue}\n          normalizedValue={normalizedValue}\n        />\n      ) : null}\n\n      {/* Ticker slider */}\n      <div\n        className=\"relative flex h-10 w-full items-center justify-center\"\n        style={{\n          maskImage:\n            \"linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)\",\n          WebkitMaskImage:\n            \"linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)\",\n        }}\n      >\n        <div\n          className=\"relative h-full w-full cursor-grab select-none active:cursor-grabbing\"\n          onPointerDown={handlePointerDown}\n          ref={containerRef}\n          style={{\n            padding: `0 calc(50% - ${NOTCH_WIDTH / 2}px)`,\n            touchAction: \"pan-y\",\n          }}\n        >\n          <motion.ul\n            className=\"relative m-0 flex h-full list-none items-center p-0\"\n            style={{ marginLeft: -centerIndex * NOTCH_WIDTH, x }}\n          >\n            {items.map((i) => (\n              <Notch centerIndex={centerIndex} index={i} key={i} x={x} />\n            ))}\n          </motion.ul>\n        </div>\n      </div>\n    </div>\n  );\n};\n\n/** Individual notch mark in the ticker */\nconst Notch = ({\n  index,\n  centerIndex,\n  x,\n}: {\n  index: number;\n  centerIndex: number;\n  x: MotionValue<number>;\n}) => {\n  // Distance from center in notch units\n  const distance = useTransform(x, (latest) => {\n    const currentCenter = centerIndex + -latest / NOTCH_WIDTH;\n    return Math.abs(index - currentCenter);\n  });\n\n  const opacity = useTransform(distance, [0, 1, 3], [1, 0.6, 0.3]);\n  const clipTop = useTransform(distance, [0, 1, 2], [0, 30, 50]);\n  const clipPath = useTransform(clipTop, (v) => `inset(${v}% 0px 0px)`);\n\n  const isCenter = useTransform(distance, (d) => d < 0.5);\n  const bg = useTransform(isCenter, (center) =>\n    center ? \"var(--es-accent)\" : \"currentColor\"\n  );\n\n  return (\n    <li\n      className=\"relative flex-shrink-0 flex-grow-0\"\n      style={{ height: \"fit-content\" }}\n    >\n      <div style={{ padding: \"0 5px\" }}>\n        <motion.div\n          className=\"rounded-sm\"\n          style={{\n            backgroundColor: bg,\n            clipPath,\n            height: 40,\n            opacity,\n            width: 3,\n            willChange: \"clip-path, opacity\",\n          }}\n        />\n      </div>\n    </li>\n  );\n};\n\n/** SVG progress ring showing positive/negative value */\nconst ProgressCircle = ({\n  normalizedValue,\n  displayValue,\n}: {\n  normalizedValue: MotionValue<number>;\n  displayValue: MotionValue<number>;\n}) => {\n  // Positive arc (right side, 0 to 1)\n  const positiveDash = useTransform(normalizedValue, (v) =>\n    v > 0 ? `${v} ${1 - v}` : \"0 1\"\n  );\n\n  // Negative arc (left side, mirrored)\n  const negativeDash = useTransform(normalizedValue, (v) =>\n    v < 0 ? `${-v} ${1 + v}` : \"0 1\"\n  );\n\n  // Color: accent when non-zero, foreground when zero\n  const color = useTransform(normalizedValue, (v) =>\n    Math.abs(v) > 0.01 ? \"var(--es-accent)\" : \"currentColor\"\n  );\n\n  return (\n    <div className=\"relative flex h-[75px] w-[75px] items-center justify-center\">\n      <svg className=\"absolute inset-0 h-full w-full\" viewBox=\"0 0 100 100\">\n        {/* Background ring */}\n        <circle\n          cx=\"50\"\n          cy=\"50\"\n          fill=\"currentColor\"\n          fillOpacity={0.067}\n          r=\"48\"\n          stroke=\"currentColor\"\n          strokeOpacity={0.3}\n          strokeWidth=\"3\"\n        />\n        {/* Positive indicator */}\n        <motion.circle\n          cx=\"50\"\n          cy=\"50\"\n          fill=\"none\"\n          pathLength={1}\n          r=\"48\"\n          stroke=\"var(--es-accent)\"\n          strokeDasharray={positiveDash}\n          strokeDashoffset={0}\n          strokeWidth=\"3\"\n          style={{\n            transform: \"rotate(-90deg)\",\n            transformBox: \"fill-box\",\n            transformOrigin: \"50% 50%\",\n          }}\n        />\n        {/* Negative indicator */}\n        <motion.circle\n          cx=\"50\"\n          cy=\"50\"\n          fill=\"none\"\n          pathLength={1}\n          r=\"48\"\n          stroke=\"var(--es-accent)\"\n          strokeDasharray={negativeDash}\n          strokeDashoffset={0}\n          strokeWidth=\"3\"\n          style={{\n            transform: \"scaleX(-1) rotate(-90deg)\",\n            transformBox: \"fill-box\",\n            transformOrigin: \"50% 50%\",\n          }}\n        />\n      </svg>\n      <motion.span className=\"absolute font-semibold text-lg\" style={{ color }}>\n        {displayValue}\n      </motion.span>\n    </div>\n  );\n};\n\nexport default ExposureSlider;\n","path":"index.tsx","target":"components/smoothui/exposure-slider/index.tsx","type":"registry:ui"}],"name":"exposure-slider","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Exposure Slider","type":"registry:ui"}