Precision dialNew

A small instrument for getting a value just right. Drag to tune; hold Shift for the finer details.

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/precision-dial.json

Overview

Give a continuous setting a little tactility. Fixed hairline ticks surround a rotating indicator, the readout follows each step directly, and the final number rolls into place when you release. Drag upwards to increase, downwards to decrease. Shift changes the sensitivity without jumping back to the starting value.

States

tune
The indicator follows the pointer without easing. Only its transform, the readout and accessible value change during movement.
commit
Release commits the snapped value. The final digits roll over 420ms; the dial stays where you left it.
adjust
Keyboard, preset and controlled changes rotate into position over 420ms. Home and End reach the limits.
cancel
Escape, focus loss, a hidden tab or pointer interruption restores the value from before the gesture.

Usage

usage.tsx
import { PrecisionDial } from "@/components/precision-dial";<PrecisionDial label="Output gain" min={-12} max={12} step={0.5}  defaultValue={0} unit="dB" onValueCommit={setGain} />

Works with

Keep expensive updates off the gesture

Commit heavy work after release. The dial maintains its immediate visual feedback while you drag; onValueChange is available for consumers that need every snapped step.

example.tsx
"use client";import { useState } from "react";import { PrecisionDial } from "@/components/precision-dial";export function GainControl() {  const [gain, setGain] = useState(0);  return <PrecisionDial label="Output gain" value={gain} min={-12} max={12}    step={0.5} unit="dB" onValueCommit={(next) => {      setGain(next);      applyGain(next);    }} />;}

Props

PropTypeDescription
label / unitstringAccessible setting name and optional unit beside the readout.
value / defaultValuenumberdefault 50Controlled or initial value. Values clamp to the range and snap to step.
min / max / stepnumberdefault 0 / 100 / 1Range and increment, with up to six decimal places.
onValueChange(value: number) => voidReceives each distinct snapped value during a gesture.
onValueCommit(value: number) => voidReceives a completed drag, keyboard adjustment or double-click reset.
disabledbooleandefault falseDisables input and cancels a gesture in progress.

Accessibility and motion

  • A native button exposes slider semantics, limits and the current value. Arrows change one step; Shift and arrows, Page Up and Page Down change ten.
  • Dragging up increases the value; horizontal movement contributes at one quarter of the vertical sensitivity. Shift makes pointer movement ten times finer.
  • Double-click resets to defaultValue. Cancellation does not call onValueCommit.
  • The rotating face is a separate compositor layer. No layout measurement, React state update or animation loop runs per pointer move.
  • Reduced motion keeps direct manipulation and removes the settling rotation and number roll.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Number roll.

Source

components/precision-dial.tsx
'use client';import * as React from 'react';import { NumberRoll } from './number-roll';import { INTERACTION_FOCUS, useInteractionReducedMotion } from './ui-interaction-motion';import { dialAngle, dialValue, usePrecisionDial } from './use-precision-dial';export interface PrecisionDialProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {  label: string; value?: number; defaultValue?: number; min?: number; max?: number; step?: number;  unit?: string; disabled?: boolean; onValueChange?: (value: number) => void; onValueCommit?: (value: number) => void;}/** A tactile control with fixed ticks and a composited needle; the number rolls only when committed. */export function PrecisionDial({ label, value, defaultValue = 50, min = 0, max = 100, step = 1, unit = '', disabled = false, onValueChange, onValueCommit, className = '', ...props }: PrecisionDialProps) {  const reduced = useInteractionReducedMotion();  const lower = Number.isFinite(min) ? min : 0;  const upper = Number.isFinite(max) && max > lower ? max : lower + 100;  const increment = Number.isFinite(step) && step > 0 ? Math.max(0.000001, step) : 1;  const [own, setOwn] = React.useState(defaultValue);  const current = dialValue(Number.isFinite(value ?? own) ? value ?? own : lower, lower, upper, increment);  const decimals = Math.min(6, Math.max(0, (String(increment).split('.')[1] ?? '').length));  const format = (n: number) => `${n.toFixed(decimals)}${unit ? ` ${unit}` : ''}`;  const change = (next: number) => onValueChange?.(next);  const commit = (next: number) => { if (value === undefined) setOwn(next); onValueCommit?.(next); };  const dial = usePrecisionDial({ value: current, min: lower, max: upper, step: increment, disabled, reduced, onChange: change, onCommit: commit, format, formatNumber: (n) => n.toFixed(decimals) });  const hint = React.useId();  return <div {...props} className={`flex flex-col items-center text-foreground ${className}`}>    <span className="text-[12px] font-medium text-muted-foreground">{label}</span>    <div className="relative my-3 size-[204px]">      <svg viewBox="0 0 204 204" aria-hidden="true" className="absolute inset-0 size-full text-muted-foreground/40">        {Array.from({ length: 37 }, (_, i) => {          const angle = (-135 + i * 7.5) * Math.PI / 180;          const outer = 95, inner = i % 6 === 0 ? 85 : 90;          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" />;        })}      </svg>      <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}        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}`}>        <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)` }}>          <span className="absolute left-1/2 top-3 h-5 w-px -translate-x-1/2 bg-primary" />          <span className="absolute inset-2 rounded-full border border-border/50" />        </div>        <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>      </button>      <span aria-hidden="true" className="absolute bottom-0 left-4 text-[10px] tabular-nums text-muted-foreground">{lower}</span>      <span aria-hidden="true" className="absolute bottom-0 right-4 text-[10px] tabular-nums text-muted-foreground">{upper}</span>    </div>    <div aria-hidden="true" className="relative min-w-24 text-center text-[34px] leading-none tracking-[-0.04em] tabular-nums">      <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>      <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>    </div>    <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>  </div>;}
components/use-precision-dial.ts
'use client';import * as React from 'react';import { EASE_OUT } from './ui-interaction-motion';export const dialAngle = (value: number, min: number, max: number) => -135 + (value - min) / (max - min) * 270;export function dialValue(value: number, min: number, max: number, step: number) {  if (!Number.isFinite(value) || value <= min) return min;  if (value >= max) return max;  const snapped = min + Math.round((value - min) / step) * step;  return Number(Math.max(min, Math.min(max, snapped)).toFixed(6));}type Options = {  value: number; min: number; max: number; step: number; disabled: boolean; reduced: boolean;  onChange: (value: number) => void; onCommit: (value: number) => void; format: (value: number) => string; formatNumber: (value: number) => string;};/** Layout is fixed. A gesture writes one rotation, one text node and the slider's accessible value. */export function usePrecisionDial(options: Options) {  const face = React.useRef<HTMLDivElement>(null);  const control = React.useRef<HTMLButtonElement>(null);  const readout = React.useRef<HTMLSpanElement>(null);  const optionsRef = React.useRef(options); optionsRef.current = options;  const gesture = React.useRef<{ id: number; x: number; y: number; value: number; raw: number; last: number } | null>(null);  const [dragging, setDragging] = React.useState(false);  const paint = React.useCallback((next: number, animate: boolean) => {    const o = optionsRef.current;    if (face.current) {      face.current.style.transition = animate && !o.reduced ? `transform 420ms ${EASE_OUT}` : 'none';      face.current.style.transform = `rotate(${dialAngle(next, o.min, o.max)}deg)`;    }    if (readout.current) readout.current.textContent = o.formatNumber(next);    control.current?.setAttribute('aria-valuenow', String(next));    control.current?.setAttribute('aria-valuetext', o.format(next));  }, []);  const commit = React.useCallback((next: number, animate = true) => {    const o = optionsRef.current;    const bounded = dialValue(next, o.min, o.max, o.step);    paint(bounded, animate); o.onChange(bounded); o.onCommit(bounded);  }, [paint]);  const finish = React.useCallback((cancelled: boolean) => {    const g = gesture.current;    if (!g) return;    gesture.current = null; setDragging(false);    if (cancelled) { paint(g.value, true); optionsRef.current.onChange(g.value); }    else commit(g.last, false);  }, [commit, paint]);  React.useLayoutEffect(() => { if (!gesture.current) paint(options.value, true); }, [options.value, options.min, options.max, options.reduced, paint]);  React.useEffect(() => {    const interrupted = () => finish(true);    const hidden = () => { if (document.hidden) interrupted(); };    window.addEventListener('blur', interrupted); document.addEventListener('visibilitychange', hidden);    return () => { window.removeEventListener('blur', interrupted); document.removeEventListener('visibilitychange', hidden); };  }, [finish]);  React.useEffect(() => { if (options.disabled) finish(true); }, [options.disabled, finish]);  return {    face, control, readout, dragging, commit,    handlers: {      onPointerDown(event: React.PointerEvent<HTMLButtonElement>) {        if (event.button !== 0 || !event.isPrimary || optionsRef.current.disabled) return;        const o = optionsRef.current;        event.currentTarget.setPointerCapture(event.pointerId); event.currentTarget.focus({ preventScroll: true });        gesture.current = { id: event.pointerId, x: event.clientX, y: event.clientY, value: o.value, raw: o.value, last: o.value };        paint(o.value, false); setDragging(true);      },      onPointerMove(event: React.PointerEvent<HTMLButtonElement>) {        const g = gesture.current, o = optionsRef.current;        if (!g || g.id !== event.pointerId) return;        const travel = g.y - event.clientY + (event.clientX - g.x) * 0.25;        g.x = event.clientX; g.y = event.clientY;        g.raw = Math.max(o.min, Math.min(o.max, g.raw + travel * (o.max - o.min) / 180 * (event.shiftKey ? 0.1 : 1)));        const next = dialValue(g.raw, o.min, o.max, o.step);        if (next === g.last) return;        g.last = next; paint(next, false); o.onChange(next);      },      onPointerUp(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(false); },      onPointerCancel(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(true); },      onLostPointerCapture(event: React.PointerEvent<HTMLButtonElement>) { if (event.pointerId === gesture.current?.id) finish(true); },      onBlur() { finish(true); },      onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {        if (event.key === 'Escape') { event.preventDefault(); finish(true); return; }        const o = optionsRef.current;        if (!['ArrowUp', 'ArrowDown', 'ArrowRight', 'ArrowLeft', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key)) return;        event.preventDefault();        const up = ['ArrowUp', 'ArrowRight', 'PageUp'].includes(event.key);        const amount = o.step * (event.key.startsWith('Page') || event.shiftKey ? 10 : 1);        commit(event.key === 'Home' ? o.min : event.key === 'End' ? o.max : o.value + (up ? amount : -amount));      },    },  };}
components/ui-interaction-motion.ts
'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';export 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';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => {  const media = window.matchMedia(query);  media.addEventListener('change', notify);  return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() {  const ref = React.useRef<T>(null);  const [visible, setVisible] = React.useState(false);  const [hidden, setHidden] = React.useState(false);  React.useEffect(() => {    const el = ref.current;    if (!el) return;    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));    observer.observe(el);    const visibility = () => setHidden(document.hidden);    visibility(); document.addEventListener('visibilitychange', visibility);    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };  }, []);  return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {  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}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) {  samples.push({ x, time });  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) {  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;  const first = samples[0], last = samples[samples.length - 1];  return (last.x - first.x) / Math.max(1, last.time - first.time);}/** A damped spring sampled by WAAPI: no React renders or perpetual frame loop. */export function springFrames(from: number, to: number, velocity = 0, duration = 520) {  const omega = 20, damping = 0.82, damped = omega * Math.sqrt(1 - damping * damping);  const displacement = from - to;  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;  return Array.from({ length: 33 }, (_, i) => {    const t = (i / 32) * duration / 1000;    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));    return { value: i === 32 ? to : value, offset: i / 32 };  });}

Registry item: /r/precision-dial.json