{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scroll-ruler", "type": "registry:ui", "title": "Scroll Ruler", "description": "A ruler-style input for fractional values — scroll or drag to scrub, spring-animated snapping, gradient-masked edges.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/scroll-ruler.tsx", "content": "\"use client\";\n\nimport { motion } from \"motion/react\";\nimport { useState, useRef, useCallback, useEffect, useMemo } from \"react\";\n\n/**\n * Scroll Ruler — Rauno Freiberg craft.\n *\n * Pure metric lines. No surface, no border.\n * Scroll or drag to scrub through values.\n * Proximity-scaled ticks — magnifying-lens bulge at center.\n * Mechanical noise-burst click sounds on each detent.\n * CSS mask-image for background-agnostic edge fading.\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: 400, damping: 30 },\n};\n\n/* ── Types ── */\n\ninterface ScrollRulerProps {\n min?: number;\n max?: number;\n step?: number;\n value?: number;\n defaultValue?: number;\n suffix?: string;\n sound?: boolean;\n labelInterval?: number;\n majorInterval?: number;\n onChange?: (value: number) => void;\n}\n\n/* ── Constants ── */\n\nconst PITCH = 10;\nconst TICK_W = 2;\n\n/* ── Audio — mechanical noise-burst detent clicks ── */\n\nlet _ctx: AudioContext | null = null;\nlet _clickBuf: AudioBuffer | null = null;\n\nfunction ctx() {\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.005); // 5ms 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 // Shaped noise — sharp attack, quartic decay\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n\n _clickBuf = buf;\n return buf;\n}\n\nfunction playClick(\n val: number,\n lastTime: React.MutableRefObject,\n labelInt: number,\n majorInt: number,\n) {\n const now = performance.now();\n if (now - lastTime.current < 20) return;\n lastTime.current = now;\n\n try {\n const ac = ctx();\n const buf = clickBuffer(ac);\n\n const isLabel = val % labelInt === 0;\n const isMajor = val % majorInt === 0;\n\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n\n src.buffer = buf;\n // Pitch: label = deeper thock, minor = lighter tap\n src.playbackRate.value = isLabel ? 0.7 : isMajor ? 0.9 : 1.3;\n // Volume: label loudest, minor subtlest\n gain.gain.value = isLabel ? 0.18 : isMajor ? 0.1 : 0.05;\n\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent fallback */\n }\n}\n\n/* ── Helper ── */\n\nfunction snapVal(v: number, step: number, min: number, max: number) {\n const s = Math.round(v / step) * step;\n const d = step < 1 ? (String(step).split(\".\")[1]?.length ?? 0) : 0;\n return Math.min(max, Math.max(min, Number(s.toFixed(d))));\n}\n\n/* ── Component ── */\n\nexport function ScrollRuler({\n min = -90,\n max = 90,\n step = 1,\n value: controlledValue,\n defaultValue = 0,\n suffix = \"°\",\n sound = true,\n labelInterval = 10,\n majorInterval = 5,\n onChange,\n}: ScrollRulerProps) {\n const [internal, setInternal] = useState(defaultValue);\n const isControlled = controlledValue !== undefined;\n const value = isControlled ? controlledValue : internal;\n\n const [isDragging, setIsDragging] = useState(false);\n const drag = useRef({ startX: 0, startVal: 0 });\n const prevVal = useRef(value);\n const lastSoundTime = useRef(0);\n const rulerAreaRef = useRef(null);\n\n /* Build ticks */\n const ticks = useMemo(() => {\n const totalSteps = Math.round((max - min) / step);\n const arr: number[] = [];\n for (let i = 0; i <= totalSteps; i++) {\n arr.push(+(min + i * step).toFixed(10));\n }\n return arr;\n }, [min, max, step]);\n\n /* Ruler x — current value centered */\n const valueIndex = (value - min) / step;\n const rulerX = -valueIndex * PITCH;\n\n /* Labels */\n const labelTicks = useMemo(\n () => ticks.filter((t) => t % labelInterval === 0),\n [ticks, labelInterval],\n );\n\n /* Set value + play sound */\n const set = useCallback(\n (next: number) => {\n if (next !== prevVal.current) {\n if (sound) playClick(next, lastSoundTime, labelInterval, majorInterval);\n prevVal.current = next;\n }\n if (!isControlled) setInternal(next);\n onChange?.(next);\n },\n [sound, isControlled, labelInterval, majorInterval, 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 drag.current = { startX: e.clientX, startVal: value };\n setIsDragging(true);\n },\n [value],\n );\n\n const onMove = useCallback(\n (e: React.PointerEvent) => {\n if (!isDragging) return;\n const dx = e.clientX - drag.current.startX;\n const dv = -(dx / PITCH) * step;\n set(snapVal(drag.current.startVal + dv, step, min, max));\n },\n [isDragging, step, min, max, set],\n );\n\n const onUp = useCallback(() => {\n setIsDragging(false);\n }, []);\n\n /* Wheel — non-passive */\n const wheelHandler = useCallback(\n (e: WheelEvent) => {\n e.preventDefault();\n const dir = e.deltaY > 0 ? 1 : -1;\n const next = snapVal(prevVal.current + dir * step, step, min, max);\n set(next);\n },\n [step, min, max, set],\n );\n\n useEffect(() => {\n const el = rulerAreaRef.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 to prevVal */\n useEffect(() => {\n prevVal.current = value;\n }, [value]);\n\n return (\n
\n
\n {/* ── Value display ── */}\n
\n \n {value}\n {suffix}\n \n
\n\n {/* ── Ruler container — CSS mask for bg-agnostic edge fade ── */}\n \n {/* Center indicator — fixed */}\n \n {/* Indicator cap */}\n \n\n {/* ── Ruler strip — slides under center indicator ── */}\n \n {/* Tick bars — proximity-scaled (magnifying lens) */}\n {ticks.map((tickVal, i) => {\n const isLabel = tickVal % labelInterval === 0;\n const isMajor = tickVal % majorInterval === 0;\n\n const dist = Math.abs(i - valueIndex);\n const prox = Math.max(0, 1 - dist / 8);\n\n const baseH = isLabel ? 24 : isMajor ? 14 : 7;\n const scale = 1 + prox * 0.5;\n\n const baseA = isLabel ? 0.4 : isMajor ? 0.15 : 0.06;\n const alpha = Math.min(1, baseA + prox * 0.35);\n\n return (\n \n );\n })}\n\n {/* Labels — separate layer, not scaled */}\n {labelTicks.map((tickVal) => {\n const i = Math.round((tickVal - min) / step);\n const dist = Math.abs(i - valueIndex);\n const prox = Math.max(0, 1 - dist / 10);\n const alpha = 0.35 + prox * 0.4;\n\n return (\n \n {tickVal}\n \n );\n })}\n \n
\n\n {/* ── Hint ── */}\n
\n Scroll or drag\n
\n
\n \n );\n}\n\nexport default ScrollRuler;\n", "type": "registry:ui", "target": "components/ruixen/scroll-ruler.tsx" } ] }