{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "drum-picker", "type": "registry:ui", "title": "Drum Picker", "description": "A 3D cylindrical drum picker — vertical drag or scroll to cycle, perspective-projected curvature, proximity-scaled brightness, spring snap.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/drum-picker.tsx", "content": "\"use client\";\n\nimport { motion } from \"motion/react\";\nimport { useState, useRef, useCallback, useEffect } from \"react\";\n\n/**\n * Drum Picker — Rauno Freiberg craft.\n *\n * 3D cylindrical drum for discrete value selection.\n * Vertical drag or scroll to cycle through items.\n * Perspective-projected items curve away from center —\n * proximity-scaled brightness gives a natural depth cue.\n * CSS mask-image for background-agnostic edge fading.\n * Scroll-delta accumulation prevents trackpad over-sensitivity.\n * Rubber-band overscroll at limits.\n * Mechanical noise-burst click on each detent.\n * Spring-animated snapping on release.\n * Supports controlled (value) and uncontrolled (defaultValue) modes.\n */\n\n/* ── Springs ── */\n\nconst spring = {\n snap: { type: \"spring\" as const, stiffness: 360, damping: 34 },\n};\n\n/* ── Types ── */\n\ninterface DrumPickerProps {\n items: string[];\n value?: string;\n defaultValue?: string;\n onChange?: (value: string) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst ANGLE_STEP = 20;\nconst RADIUS = 150;\nconst ITEM_H = 40;\nconst VISIBLE_HALF = 5;\nconst SCROLL_THRESHOLD = 50;\n\n/* ── Audio — mechanical noise-burst detent clicks ── */\n\nlet _ctx: AudioContext | null = null;\nlet _clickBuf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction clickBuffer(ac: AudioContext): AudioBuffer {\n if (_clickBuf && _clickBuf.sampleRate === ac.sampleRate) return _clickBuf;\n\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.004); // 4ms burst\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n\n _clickBuf = buf;\n return buf;\n}\n\nfunction playDetent(lastTime: React.MutableRefObject) {\n const now = performance.now();\n if (now - lastTime.current < 25) return;\n lastTime.current = now;\n\n try {\n const ac = audioCtx();\n const buf = clickBuffer(ac);\n\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n\n src.buffer = buf;\n src.playbackRate.value = 0.8;\n gain.gain.value = 0.12;\n\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent fallback */\n }\n}\n\n/* ── Helpers ── */\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.max(lo, Math.min(hi, v));\n}\n\n/* ── Theme ── */\n\nconst DP_CSS = `.dp{--dp-ink:0,0,0}.dark .dp,[data-theme=\"dark\"] .dp{--dp-ink:255,255,255}`;\n\n/* ── Component ── */\n\nexport function DrumPicker({\n items,\n value: controlledValue,\n defaultValue,\n onChange,\n sound = true,\n}: DrumPickerProps) {\n const defaultIdx = defaultValue\n ? Math.max(0, items.indexOf(defaultValue))\n : 0;\n const [internalIdx, setInternalIdx] = useState(defaultIdx);\n\n const isControlled = controlledValue !== undefined;\n const currentIdx = isControlled\n ? Math.max(0, items.indexOf(controlledValue))\n : internalIdx;\n\n const [isDragging, setIsDragging] = useState(false);\n const [dragAngle, setDragAngle] = useState(0);\n const dragRef = useRef({ startY: 0, startAngle: 0 });\n const prevIdx = useRef(currentIdx);\n const lastSoundTime = useRef(0);\n const scrollAccum = useRef(0);\n const containerRef = useRef(null);\n\n const targetAngle = currentIdx * ANGLE_STEP;\n const displayAngle = isDragging ? dragAngle : targetAngle;\n\n /* Set value + sound */\n const set = useCallback(\n (idx: number) => {\n const c = clamp(idx, 0, items.length - 1);\n if (c !== prevIdx.current) {\n if (sound) playDetent(lastSoundTime);\n prevIdx.current = c;\n }\n if (!isControlled) setInternalIdx(c);\n onChange?.(items[c]);\n },\n [sound, isControlled, items, onChange],\n );\n\n /* ── Pointer handlers ── */\n\n const onDown = useCallback(\n (e: React.PointerEvent) => {\n e.preventDefault();\n e.currentTarget.setPointerCapture(e.pointerId);\n const angle = currentIdx * ANGLE_STEP;\n dragRef.current = { startY: e.clientY, startAngle: angle };\n setDragAngle(angle);\n setIsDragging(true);\n },\n [currentIdx],\n );\n\n const onMove = useCallback(\n (e: React.PointerEvent) => {\n if (!isDragging) return;\n const dy = e.clientY - dragRef.current.startY;\n let newAngle = dragRef.current.startAngle - dy * 0.35;\n\n // Rubber-band overscroll — diminishing drag past limits\n const minA = 0;\n const maxA = (items.length - 1) * ANGLE_STEP;\n if (newAngle < minA) {\n const over = minA - newAngle;\n newAngle = minA - over * 0.12;\n } else if (newAngle > maxA) {\n const over = newAngle - maxA;\n newAngle = maxA + over * 0.12;\n }\n\n setDragAngle(newAngle);\n\n // Sound on detent crossing\n const nearIdx = clamp(\n Math.round(newAngle / ANGLE_STEP),\n 0,\n items.length - 1,\n );\n if (nearIdx !== prevIdx.current) {\n if (sound) playDetent(lastSoundTime);\n prevIdx.current = nearIdx;\n }\n },\n [isDragging, items.length, sound],\n );\n\n const onUp = useCallback(() => {\n if (!isDragging) return;\n setIsDragging(false);\n const nearIdx = clamp(\n Math.round(dragAngle / ANGLE_STEP),\n 0,\n items.length - 1,\n );\n set(nearIdx);\n }, [isDragging, dragAngle, items.length, set]);\n\n /* Wheel — accumulated delta, non-passive */\n const wheelHandler = useCallback(\n (e: WheelEvent) => {\n e.preventDefault();\n scrollAccum.current += e.deltaY;\n\n if (Math.abs(scrollAccum.current) >= SCROLL_THRESHOLD) {\n const dir = Math.sign(scrollAccum.current);\n scrollAccum.current = 0;\n set(prevIdx.current + dir);\n }\n },\n [set],\n );\n\n useEffect(() => {\n const el = containerRef.current;\n if (!el) return;\n el.addEventListener(\"wheel\", wheelHandler, { passive: false });\n return () => el.removeEventListener(\"wheel\", wheelHandler);\n }, [wheelHandler]);\n\n /* Sync controlled value */\n useEffect(() => {\n prevIdx.current = currentIdx;\n }, [currentIdx]);\n\n /* Container height */\n const viewH = ITEM_H * (VISIBLE_HALF * 2 + 1);\n\n return (\n
\n