{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"precision-dial","type":"registry:component","title":"Precision dial","description":"A small instrument for getting a value just right. Drag to tune; hold Shift for the finer details.","author":"Hairline UI","dependencies":[],"registryDependencies":["https://hairlineui.com/r/number-roll.json"],"files":[{"path":"registry/precision-dial.tsx","type":"registry:component","content":"// Hairline UI · precision-dial · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { NumberRoll } from './number-roll';\nimport { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';\nimport { dialAngle, dialValue, usePrecisionDial } from './use-precision-dial';\n\nexport interface PrecisionDialProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {\n  label: string; value?: number; defaultValue?: number; min?: number; max?: number; step?: number;\n  unit?: string; disabled?: boolean; onValueChange?: (value: number) => void; onValueCommit?: (value: number) => void;\n}\n\n/** A tactile control with fixed ticks and a composited needle; the number rolls only when committed. */\nexport function PrecisionDial({ label, value, defaultValue = 50, min = 0, max = 100, step = 1, unit = '', disabled = false, onValueChange, onValueCommit, className = '', ...props }: PrecisionDialProps) {\n  const reduced = useInteractionReducedMotion();\n  const lower = Number.isFinite(min) ? min : 0;\n  const upper = Number.isFinite(max) && max > lower ? max : lower + 100;\n  const increment = Number.isFinite(step) && step > 0 ? Math.max(0.000001, step) : 1;\n  const [own, setOwn] = React.useState(defaultValue);\n  const current = dialValue(Number.isFinite(value ?? own) ? value ?? own : lower, lower, upper, increment);\n  const decimals = Math.min(6, Math.max(0, (String(increment).split('.')[1] ?? '').length));\n  const format = (n: number) => `${n.toFixed(decimals)}${unit ? ` ${unit}` : ''}`;\n  const change = (next: number) => onValueChange?.(next);\n  const commit = (next: number) => { if (value === undefined) setOwn(next); onValueCommit?.(next); };\n  const dial = usePrecisionDial({ value: current, min: lower, max: upper, step: increment, disabled, reduced, onChange: change, onCommit: commit, format, formatNumber: (n) => n.toFixed(decimals) });\n  const hint = React.useId();\n  return <div {...props} className={`flex flex-col items-center text-foreground ${className}`}>\n    <span className=\"text-[12px] font-medium text-muted-foreground\">{label}</span>\n    <div className=\"relative my-3 size-[204px]\">\n      <svg viewBox=\"0 0 204 204\" aria-hidden=\"true\" className=\"absolute inset-0 size-full text-muted-foreground/40\">\n        {Array.from({ length: 37 }, (_, i) => {\n          const angle = (-135 + i * 7.5) * Math.PI / 180;\n          const outer = 95, inner = i % 6 === 0 ? 85 : 90;\n          return <line key={i} x1={102 + Math.sin(angle) * inner} y1={102 - Math.cos(angle) * inner} x2={102 + Math.sin(angle) * outer} y2={102 - Math.cos(angle) * outer} stroke=\"currentColor\" strokeWidth=\"1\" strokeLinecap=\"round\" />;\n        })}\n      </svg>\n      <button ref={dial.control} {...dial.handlers} type=\"button\" role=\"slider\" disabled={disabled} aria-label={label} aria-valuemin={lower} aria-valuemax={upper} aria-valuenow={current} aria-valuetext={format(current)} aria-describedby={hint}\n        onDoubleClick={() => dial.commit(defaultValue)} className={`absolute inset-7 cursor-ns-resize touch-none select-none rounded-full disabled:cursor-default disabled:opacity-40 ${INTERACTION_FOCUS}`}>\n        <div ref={dial.face} aria-hidden=\"true\" className=\"absolute inset-0 rounded-full border border-border bg-background will-change-transform\" style={{ transform: `rotate(${dialAngle(current, lower, upper)}deg)` }}>\n          <span className=\"absolute left-1/2 top-3 h-5 w-px -translate-x-1/2 bg-primary\" />\n          <span className=\"absolute inset-2 rounded-full border border-border/50\" />\n        </div>\n        <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 flex items-center justify-center text-[10px] font-mono uppercase tracking-[0.22em] text-muted-foreground\">Fine tune</span>\n      </button>\n      <span aria-hidden=\"true\" className=\"absolute bottom-0 left-4 text-[10px] tabular-nums text-muted-foreground\">{lower}</span>\n      <span aria-hidden=\"true\" className=\"absolute bottom-0 right-4 text-[10px] tabular-nums text-muted-foreground\">{upper}</span>\n    </div>\n    <div aria-hidden=\"true\" className=\"relative min-w-24 text-center text-[34px] leading-none tracking-[-0.04em] tabular-nums\">\n      <span style={{ opacity: dial.dragging ? 0 : 1 }}><NumberRoll value={current} format={{ minimumFractionDigits: decimals, maximumFractionDigits: decimals }} duration={420} /><span className=\"ml-1.5 text-[13px] tracking-normal text-muted-foreground\">{unit}</span></span>\n      <span className=\"absolute inset-0 whitespace-nowrap\" style={{ opacity: dial.dragging ? 1 : 0 }}><span ref={dial.readout}>{current.toFixed(decimals)}</span><span className=\"ml-1.5 text-[13px] tracking-normal text-muted-foreground\">{unit}</span></span>\n    </div>\n    <p id={hint} className=\"sr-only\">Drag up to increase, down to decrease. Hold Shift for fine control. Arrows adjust one step, Page Up and Page Down ten. Home and End reach the limits. Escape cancels. Double-click resets.</p>\n  </div>;\n}\n"},{"path":"registry/use-precision-dial.ts","type":"registry:component","content":"// Hairline UI · precision-dial · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\nimport { EASE_OUT } from './ui-interaction-motion';\n\nexport const dialAngle = (value: number, min: number, max: number) => -135 + (value - min) / (max - min) * 270;\nexport function dialValue(value: number, min: number, max: number, step: number) {\n  if (!Number.isFinite(value) || value <= min) return min;\n  if (value >= max) return max;\n  const snapped = min + Math.round((value - min) / step) * step;\n  return Number(Math.max(min, Math.min(max, snapped)).toFixed(6));\n}\n\ntype Options = {\n  value: number; min: number; max: number; step: number; disabled: boolean; reduced: boolean;\n  onChange: (value: number) => void; onCommit: (value: number) => void; format: (value: number) => string; formatNumber: (value: number) => string;\n};\n\n/** Layout is fixed. A gesture writes one rotation, one text node and the slider's accessible value. */\nexport function usePrecisionDial(options: Options) {\n  const face = React.useRef<HTMLDivElement>(null);\n  const control = React.useRef<HTMLButtonElement>(null);\n  const readout = React.useRef<HTMLSpanElement>(null);\n  const optionsRef = React.useRef(options); optionsRef.current = options;\n  const gesture = React.useRef<{ id: number; x: number; y: number; value: number; raw: number; last: number } | null>(null);\n  const [dragging, setDragging] = React.useState(false);\n\n  const paint = React.useCallback((next: number, animate: boolean) => {\n    const o = optionsRef.current;\n    if (face.current) {\n      face.current.style.transition = animate && !o.reduced ? `transform 420ms ${EASE_OUT}` : 'none';\n      face.current.style.transform = `rotate(${dialAngle(next, o.min, o.max)}deg)`;\n    }\n    if (readout.current) readout.current.textContent = o.formatNumber(next);\n    control.current?.setAttribute('aria-valuenow', String(next));\n    control.current?.setAttribute('aria-valuetext', o.format(next));\n  }, []);\n\n  const commit = React.useCallback((next: number, animate = true) => {\n    const o = optionsRef.current;\n    const bounded = dialValue(next, o.min, o.max, o.step);\n    paint(bounded, animate); o.onChange(bounded); o.onCommit(bounded);\n  }, [paint]);\n\n  const finish = React.useCallback((cancelled: boolean) => {\n    const g = gesture.current;\n    if (!g) return;\n    gesture.current = null; setDragging(false);\n    if (cancelled) { paint(g.value, true); optionsRef.current.onChange(g.value); }\n    else commit(g.last, false);\n  }, [commit, paint]);\n\n  React.useLayoutEffect(() => { if (!gesture.current) paint(options.value, true); }, [options.value, options.min, options.max, options.reduced, paint]);\n  React.useEffect(() => {\n    const interrupted = () => finish(true);\n    const hidden = () => { if (document.hidden) interrupted(); };\n    window.addEventListener('blur', interrupted); document.addEventListener('visibilitychange', hidden);\n    return () => { window.removeEventListener('blur', interrupted); document.removeEventListener('visibilitychange', hidden); };\n  }, [finish]);\n  React.useEffect(() => { if (options.disabled) finish(true); }, [options.disabled, finish]);\n\n  return {\n    face, control, readout, dragging, commit,\n    handlers: {\n      onPointerDown(event: React.PointerEvent<HTMLButtonElement>) {\n        if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return;\n        const o = optionsRef.current;\n        event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true });\n        gesture.current = { id: event.pointerId, x: event.clientX, y: event.clientY, value: o.value, raw: o.value, last: o.value };\n        paint(o.value, false); setDragging(true);\n      },\n      onPointerMove(event: React.PointerEvent<HTMLButtonElement>) {\n        const g = gesture.current, o = optionsRef.current;\n        if (!g || g.id !== event.pointerId) return;\n        const travel = g.y - event.clientY + (event.clientX - g.x) * 0.25;\n        g.x = event.clientX; g.y = event.clientY;\n        g.raw = Math.max(o.min, Math.min(o.max, g.raw + travel * (o.max - o.min) / 180 * (event.shiftKey ? 0.1 : 1)));\n        const next = dialValue(g.raw, o.min, o.max, o.step);\n        if (next === g.last) return;\n        g.last = next; paint(next, false); o.onChange(next);\n      },\n      onPointerUp(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(false); },\n      onPointerCancel(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(true); },\n      onLostPointerCapture(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(true); },\n      onBlur() { finish(true); },\n      onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {\n        if (event.key === 'Escape') { event.preventDefault(); finish(true); return; }\n        const o = optionsRef.current;\n        if (!['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) return;\n        event.preventDefault();\n        const up = ['ArrowUp', 'ArrowRight', 'PageUp'].includes(event.key);\n        const amount = o.step * (event.key.startsWith('Page') || event.shiftKey ? 10 : 1);\n        commit(event.key === 'Home' ? o.min : event.key === 'End' ? o.max : o.value + (up ? amount : -amount));\n      },\n    },\n  };\n}\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · precision-dial · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\nexport const INTERACTION_FOCUS = 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */\nexport function springFrames(from: number, to: number, velocity = 0, duration = 520) {\n  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: 33 }, (_, i) => {\n    const t = (i / 32) * duration / 1000;\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === 32 ? to : value, offset: i / 32 };\n  });\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/precision-dial. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}