{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "circular-stepper-input",
  "type": "registry:ui",
  "title": "Circular Stepper Input",
  "description": "iOS 26 liquid-glass circular stepper with draggable ring, spring-animated arc, and haptic sound feedback.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/ruixenui/circular-stepper-input.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\n\n/* ── sound ── */\nlet _a: AudioContext, _b: AudioBuffer;\nconst tick = () => {\n  if (typeof window === \"undefined\") return;\n  if (!_a) {\n    _a = new AudioContext();\n    _b = _a.createBuffer(1, (_a.sampleRate * 0.003) | 0, _a.sampleRate);\n    const d = _b.getChannelData(0);\n    for (let i = 0; i < d.length; i++)\n      d[i] = (Math.random() * 2 - 1) * (1 - i / d.length) ** 4;\n  }\n  const s = _a.createBufferSource();\n  s.buffer = _b;\n  const g = _a.createGain();\n  g.gain.value = 0.08;\n  s.connect(g).connect(_a.destination);\n  s.start();\n};\n\n/* ── theme ── */\nconst CSS = `\n.cs{\n  --cs-glass:linear-gradient(180deg,rgba(255,255,255,0.78),rgba(255,255,255,0.62));\n  --cs-border:rgba(0,0,0,0.06);\n  --cs-shadow:0 0 1px rgba(0,0,0,0.04),0 2px 8px rgba(0,0,0,0.04),inset 0 1px 0 rgba(255,255,255,0.8);\n  --cs-dim:rgba(0,0,0,0.42);\n  --cs-mid:rgba(0,0,0,0.55);\n  --cs-hi:rgba(0,0,0,0.88);\n  --cs-track:rgba(0,0,0,0.06);\n  --cs-fill:rgba(0,0,0,0.55);\n  --cs-btn:rgba(0,0,0,0.03);\n  --cs-btn-h:rgba(0,0,0,0.07);\n  --cs-btn-border:rgba(0,0,0,0.06)\n}\n.dark .cs,[data-theme=\"dark\"] .cs{\n  --cs-glass:linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02));\n  --cs-border:rgba(255,255,255,0.07);\n  --cs-shadow:0 1px 3px rgba(0,0,0,0.08),inset 0 1px 0 rgba(255,255,255,0.04);\n  --cs-dim:rgba(255,255,255,0.28);\n  --cs-mid:rgba(255,255,255,0.5);\n  --cs-hi:rgba(255,255,255,0.88);\n  --cs-track:rgba(255,255,255,0.06);\n  --cs-fill:rgba(255,255,255,0.55);\n  --cs-btn:rgba(255,255,255,0.03);\n  --cs-btn-h:rgba(255,255,255,0.07);\n  --cs-btn-border:rgba(255,255,255,0.06)\n}`;\n\n/* ── types ── */\ninterface CircularStepperInputProps {\n  min?: number;\n  max?: number;\n  step?: number;\n  defaultValue?: number;\n  size?: number;\n  label?: string;\n  onChange?: (value: number) => void;\n  sound?: boolean;\n  style?: React.CSSProperties;\n}\n\n/* ── component ── */\nexport default function CircularStepperInput({\n  min = 0,\n  max = 100,\n  step = 1,\n  defaultValue = 50,\n  size = 140,\n  label,\n  onChange,\n  sound = true,\n  style,\n}: CircularStepperInputProps) {\n  const [value, setValue] = React.useState(defaultValue);\n  const dragging = React.useRef(false);\n  const prevSnap = React.useRef(value);\n\n  const radius = size / 2 - 12;\n  const circumference = 2 * Math.PI * radius;\n  const percentage = (value - min) / (max - min);\n  const offset = circumference * (1 - percentage);\n\n  const setVal = React.useCallback(\n    (v: number) => {\n      setValue(v);\n      onChange?.(v);\n    },\n    [onChange],\n  );\n\n  const increment = () => {\n    const next = Math.min(value + step, max);\n    if (next !== value) {\n      if (sound) tick();\n      setVal(next);\n    }\n  };\n\n  const decrement = () => {\n    const next = Math.max(value - step, min);\n    if (next !== value) {\n      if (sound) tick();\n      setVal(next);\n    }\n  };\n\n  /* ── drag on ring ── */\n  const angleToValue = (e: React.PointerEvent<SVGSVGElement>) => {\n    const rect = e.currentTarget.getBoundingClientRect();\n    const dx = e.clientX - rect.left - rect.width / 2;\n    const dy = e.clientY - rect.top - rect.height / 2;\n    let angle = Math.atan2(dx, -dy);\n    if (angle < 0) angle += Math.PI * 2;\n    const pct = angle / (Math.PI * 2);\n    const raw = min + pct * (max - min);\n    return Math.max(min, Math.min(max, Math.round(raw / step) * step));\n  };\n\n  const onPointerDown = (e: React.PointerEvent<SVGSVGElement>) => {\n    e.currentTarget.setPointerCapture(e.pointerId);\n    dragging.current = true;\n    const v = angleToValue(e);\n    prevSnap.current = v;\n    if (sound) tick();\n    setVal(v);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<SVGSVGElement>) => {\n    if (!dragging.current) return;\n    const v = angleToValue(e);\n    if (v !== prevSnap.current) {\n      if (sound) tick();\n      prevSnap.current = v;\n    }\n    setVal(v);\n  };\n\n  const onPointerUp = () => {\n    dragging.current = false;\n  };\n\n  return (\n    <>\n      <style dangerouslySetInnerHTML={{ __html: CSS }} />\n      <motion.div\n        className=\"cs\"\n        initial={{ opacity: 0, y: 8 }}\n        animate={{ opacity: 1, y: 0 }}\n        transition={{ duration: 0.35, ease: [0.25, 0.1, 0.25, 1] }}\n        style={{\n          display: \"inline-flex\",\n          flexDirection: \"column\",\n          alignItems: \"center\",\n          gap: 16,\n          padding: 24,\n          borderRadius: 14,\n          background: \"var(--cs-glass)\",\n          border: \"1px solid var(--cs-border)\",\n          boxShadow: \"var(--cs-shadow)\",\n          backdropFilter: \"blur(24px)\",\n          WebkitBackdropFilter: \"blur(24px)\",\n          ...style,\n        }}\n      >\n        {label && (\n          <span\n            style={{\n              fontSize: 11,\n              fontWeight: 500,\n              letterSpacing: \"0.06em\",\n              textTransform: \"uppercase\" as const,\n              color: \"var(--cs-dim)\",\n              userSelect: \"none\" as const,\n            }}\n          >\n            {label}\n          </span>\n        )}\n\n        {/* ring */}\n        <div style={{ position: \"relative\", width: size, height: size }}>\n          <svg\n            width={size}\n            height={size}\n            style={{\n              transform: \"rotate(-90deg)\",\n              cursor: \"pointer\",\n              touchAction: \"none\" as const,\n            }}\n            onPointerDown={onPointerDown}\n            onPointerMove={onPointerMove}\n            onPointerUp={onPointerUp}\n          >\n            <circle\n              cx={size / 2}\n              cy={size / 2}\n              r={radius}\n              fill=\"none\"\n              stroke=\"var(--cs-track)\"\n              strokeWidth={5}\n            />\n            <motion.circle\n              cx={size / 2}\n              cy={size / 2}\n              r={radius}\n              fill=\"none\"\n              stroke=\"var(--cs-fill)\"\n              strokeWidth={5}\n              strokeLinecap=\"round\"\n              strokeDasharray={circumference}\n              animate={{ strokeDashoffset: offset }}\n              transition={{ type: \"spring\", stiffness: 400, damping: 30 }}\n            />\n          </svg>\n\n          {/* center value */}\n          <div\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              display: \"flex\",\n              flexDirection: \"column\",\n              alignItems: \"center\",\n              justifyContent: \"center\",\n              pointerEvents: \"none\" as const,\n            }}\n          >\n            <AnimatePresence mode=\"popLayout\">\n              <motion.span\n                key={value}\n                initial={{ opacity: 0.4, scale: 0.85 }}\n                animate={{ opacity: 1, scale: 1 }}\n                transition={{ type: \"spring\", stiffness: 500, damping: 30 }}\n                style={{\n                  fontSize: 28,\n                  fontWeight: 600,\n                  fontVariantNumeric: \"tabular-nums\",\n                  color: \"var(--cs-hi)\",\n                  lineHeight: 1,\n                }}\n              >\n                {value}\n              </motion.span>\n            </AnimatePresence>\n            <span\n              style={{ fontSize: 10, color: \"var(--cs-dim)\", marginTop: 4 }}\n            >\n              of {max}\n            </span>\n          </div>\n        </div>\n\n        {/* buttons */}\n        <div style={{ display: \"flex\", gap: 8 }}>\n          <GlassBtn label=\"−\" onClick={decrement} disabled={value <= min} />\n          <GlassBtn label=\"+\" onClick={increment} disabled={value >= max} />\n        </div>\n\n        {/* min / max */}\n        <div\n          style={{\n            display: \"flex\",\n            justifyContent: \"space-between\",\n            width: \"100%\",\n            fontSize: 10,\n            color: \"var(--cs-dim)\",\n            fontVariantNumeric: \"tabular-nums\",\n          }}\n        >\n          <span>{min}</span>\n          <span>{max}</span>\n        </div>\n      </motion.div>\n    </>\n  );\n}\n\n/* ── glass button ── */\nfunction GlassBtn({\n  label,\n  onClick,\n  disabled,\n}: {\n  label: string;\n  onClick: () => void;\n  disabled: boolean;\n}) {\n  return (\n    <motion.button\n      onClick={onClick}\n      disabled={disabled}\n      whileTap={disabled ? undefined : { scale: 0.9 }}\n      style={{\n        width: 40,\n        height: 40,\n        borderRadius: 10,\n        border: \"1px solid var(--cs-btn-border)\",\n        background: \"var(--cs-btn)\",\n        color: disabled ? \"var(--cs-dim)\" : \"var(--cs-hi)\",\n        fontSize: 18,\n        fontWeight: 400,\n        cursor: disabled ? \"default\" : \"pointer\",\n        display: \"flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        opacity: disabled ? 0.4 : 1,\n        transition: \"background 0.15s, opacity 0.15s\",\n      }}\n      onMouseEnter={(e) => {\n        if (!disabled)\n          (e.currentTarget as HTMLElement).style.background = \"var(--cs-btn-h)\";\n      }}\n      onMouseLeave={(e) => {\n        (e.currentTarget as HTMLElement).style.background = \"var(--cs-btn)\";\n      }}\n    >\n      {label}\n    </motion.button>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ruixen/circular-stepper-input.tsx"
    }
  ]
}