{ "$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) => {\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) => {\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) => {\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