{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dial",
  "type": "registry:ui",
  "title": "Dial",
  "description": "Rotary knob with drag, wheel and keys, a bipolar arc and rolling readout.",
  "categories": [
    "sliders"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/dial.tsx",
      "content": "\"use client\";\nimport NumberFlow, { type Format } from \"@number-flow/react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n// The knob sweeps 270 degrees, leaving the gap at the bottom where hardware puts it.\nconst SWEEP = 270;\nconst START = -SWEEP / 2;\n\nconst SIZES = {\n  sm: { box: 40, knob: 30, stroke: 1.75 },\n  md: { box: 56, knob: 44, stroke: 2 },\n  lg: { box: 72, knob: 58, stroke: 2 },\n} as const;\n\n// Digit roll timing for the readout, on the library's entrance curve.\nconst ROLL = { duration: 420, easing: `cubic-bezier(${ease.out.join(\",\")})` };\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\nfunction snap(value: number, min: number, max: number, step: number) {\n  const snapped = Math.round((value - min) / step) * step + min;\n  // Trim float noise (0.1 + 0.2) so readouts and aria values stay clean.\n  const decimals = (String(step).split(\".\")[1] ?? \"\").length;\n  return clamp(Number(snapped.toFixed(decimals)), min, max);\n}\n\n/** Point on a circle, 0deg at twelve o'clock, clockwise. */\nfunction polar(c: number, r: number, deg: number) {\n  const a = (deg * Math.PI) / 180;\n  return [c + r * Math.sin(a), c - r * Math.cos(a)] as const;\n}\n\nfunction arc(c: number, r: number, from: number, to: number) {\n  const a = Math.min(from, to);\n  const b = Math.max(from, to);\n  if (b - a < 0.01) return \"\";\n  const [x0, y0] = polar(c, r, a);\n  const [x1, y1] = polar(c, r, b);\n  return `M ${x0} ${y0} A ${r} ${r} 0 ${b - a > 180 ? 1 : 0} 1 ${x1} ${y1}`;\n}\n\n/** A number that rolls with an optional prefix and suffix, or fixed text such as \"C\" for center. */\nexport type DialDisplay = { value: number; prefix?: string; suffix?: string } | string;\n\nexport type DialProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"children\"> & {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Fires once a gesture settles: pointer released, key pressed, wheel idle. */\n  onValueCommitted?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Step for Shift+Arrow and Page Up/Down. Defaults to a tenth of the range. */\n  largeStep?: number;\n  /** Where the fill starts. Set it to the center (0 for pan or gain) for a bipolar dial. Defaults to min. */\n  origin?: number;\n  /** Value restored by double-click, Delete or Backspace. Defaults to defaultValue, then origin. */\n  resetValue?: number;\n  label?: React.ReactNode;\n  size?: keyof typeof SIZES;\n  /** Vertical: drag up or right to raise, like most audio tools. Circular: turn it around its center. */\n  dragMode?: \"vertical\" | \"circular\";\n  /** Pixels of vertical drag that cover the whole range. Shift divides the speed by ten. */\n  sensitivity?: number;\n  format?: Format;\n  locale?: Intl.LocalesArgument;\n  /** Short unit after the number, e.g. \" dB\" or \"%\". */\n  unit?: string;\n  /** Full control over the readout, e.g. turning -20 into L 20. */\n  formatDisplay?: (value: number) => DialDisplay;\n  /** Screen reader text for the value. Defaults to the readout as text. */\n  getValueText?: (value: number) => string;\n  disabled?: boolean;\n  readOnly?: boolean;\n  /** Submits the value with a form. */\n  name?: string;\n};\n\nexport function Dial({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep,\n  origin,\n  resetValue,\n  label,\n  size = \"md\",\n  dragMode = \"vertical\",\n  sensitivity = 200,\n  format,\n  locale,\n  unit,\n  formatDisplay,\n  getValueText,\n  disabled = false,\n  readOnly = false,\n  name,\n  className,\n  \"aria-label\": ariaLabel,\n  ...rest\n}: DialProps) {\n  const initial = defaultValue ?? origin ?? min;\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue: initial, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const labelId = useId();\n  const knobRef = useRef<HTMLDivElement>(null);\n  const [dragging, setDragging] = useState(false);\n\n  const range = max - min;\n  const from = clamp(origin ?? min, min, max);\n  const big = largeStep ?? Math.max(step, snap(range / 10, 0, range, step));\n  const reset = clamp(resetValue ?? defaultValue ?? origin ?? min, min, max);\n  const interactive = !disabled && !readOnly;\n  const geo = SIZES[size];\n  const c = geo.box / 2;\n  const r = c - geo.stroke / 2 - 0.5;\n  const toAngle = (v: number) => START + ((clamp(v, min, max) - min) / range) * SWEEP;\n  const target = toAngle(value);\n\n  // One motion value drives both the pointer and the arc, so a reset can glide\n  // both together while drag and keys stay 1:1.\n  const angle = useMotionValue(target);\n  const stretch = useMotionValue(0);\n  const rotate = useTransform(() => angle.get() + stretch.get());\n  const fillPath = useTransform(angle, (a) => arc(c, r, toAngle(from), clamp(a, START, -START)));\n  const glide = useRef(false);\n\n  useLayoutEffect(() => {\n    if (glide.current && !reduce) animate(angle, target, spring.soft);\n    else angle.jump(target);\n    glide.current = false;\n  }, [target, angle, reduce]);\n\n  // Live values the gesture handlers read without re-subscribing.\n  const live = useRef({ value, setValue, min, max, step, range, interactive, reduce, onValueCommitted, sensitivity, dragMode });\n  useLayoutEffect(() => {\n    live.current = { value, setValue, min, max, step, range, interactive, reduce, onValueCommitted, sensitivity, dragMode };\n  });\n\n  const drag = useRef<{ x: number; y: number; t: number; angle: number; over: number; startValue: number; lastValue: number } | null>(null);\n  const wheel = useRef({ t: 0, at: 0, timer: 0, start: 0 });\n\n  const commit = (v: number) => onValueCommitted?.(v);\n  const setFromT = (t: number) => {\n    const { min: lo, max: hi, step: s, range: span } = live.current;\n    const next = snap(lo + clamp(t, 0, 1) * span, lo, hi, s);\n    setValue(next);\n    return next;\n  };\n\n  // A small, springy end stop: pushing past a limit leans the pointer a few\n  // degrees further, and it settles back when you let go or turn back.\n  const lean = (excess: number) => {\n    if (live.current.reduce) return;\n    const deg = Math.sign(excess) * 6 * (1 - 1 / (1 + Math.abs(excess) * 4));\n    stretch.set(deg);\n  };\n  const settle = () => {\n    if (stretch.get() !== 0) animate(stretch, 0, spring.snappy);\n  };\n\n  // Wheel only acts while the dial has focus, so scrolling the page never gets caught on it.\n  useEffect(() => {\n    const el = knobRef.current;\n    if (!el) return;\n    const onWheel = (e: WheelEvent) => {\n      const l = live.current;\n      if (!l.interactive || document.activeElement !== el) return;\n      e.preventDefault();\n      const w = wheel.current;\n      const now = performance.now();\n      if (now - w.at > 400) {\n        w.t = (l.value - l.min) / l.range;\n        w.start = l.value;\n      }\n      w.at = now;\n      const delta = -(e.deltaY || -e.deltaX) * (e.shiftKey ? 0.00006 : 0.0006);\n      const raw = w.t + delta;\n      w.t = clamp(raw, 0, 1);\n      if (raw > 1 || raw < 0) lean((raw - w.t) * 3 + (raw > 1 ? 0.05 : -0.05));\n      const { min: lo, max: hi, step: s, range: span } = l;\n      const next = snap(lo + w.t * span, lo, hi, s);\n      l.setValue(next);\n      window.clearTimeout(w.timer);\n      w.timer = window.setTimeout(() => {\n        settle();\n        if (next !== w.start) live.current.onValueCommitted?.(next);\n        w.at = 0;\n      }, 260);\n    };\n    el.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => {\n      el.removeEventListener(\"wheel\", onWheel);\n      window.clearTimeout(wheel.current.timer);\n    };\n    // The handler reads everything it needs from `live`, so it binds once.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const pointerAngle = (e: React.PointerEvent) => {\n    const box = knobRef.current!.getBoundingClientRect();\n    return (Math.atan2(e.clientX - (box.left + box.width / 2), -(e.clientY - (box.top + box.height / 2))) * 180) / Math.PI;\n  };\n\n  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!interactive || e.button !== 0) return;\n    // Native mousedown focuses the knob (so the wheel and keys work next) without\n    // showing the keyboard focus ring.\n    e.currentTarget.setPointerCapture(e.pointerId);\n    // The value never jumps on grab: the gesture moves it relative to where it was.\n    drag.current = { x: e.clientX, y: e.clientY, t: (value - min) / range, angle: pointerAngle(e), over: 0, startValue: value, lastValue: value };\n    setDragging(true);\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    const d = drag.current;\n    if (!d) return;\n    let dt: number;\n    if (live.current.dragMode === \"circular\") {\n      const a = pointerAngle(e);\n      let delta = a - d.angle;\n      if (delta > 180) delta -= 360;\n      if (delta < -180) delta += 360;\n      d.angle = a;\n      dt = (delta / SWEEP) * (e.shiftKey ? 0.1 : 1);\n    } else {\n      dt = ((e.clientX - d.x - (e.clientY - d.y)) / live.current.sensitivity) * (e.shiftKey ? 0.1 : 1);\n    }\n    d.x = e.clientX;\n    d.y = e.clientY;\n\n    const atEdge = (d.t >= 1 && dt > 0) || (d.t <= 0 && dt < 0);\n    if (atEdge) {\n      d.over += dt;\n      lean(d.over);\n      return;\n    }\n    // Turning back from a limit moves the value straight away; the lean springs home.\n    if (d.over !== 0) {\n      d.over = 0;\n      settle();\n    }\n    d.t = clamp(d.t + dt, 0, 1);\n    d.lastValue = setFromT(d.t);\n  };\n\n  const endDrag = () => {\n    const d = drag.current;\n    if (!d) return;\n    drag.current = null;\n    setDragging(false);\n    settle();\n    if (d.lastValue !== d.startValue) commit(d.lastValue);\n  };\n\n  const nudge = (next: number) => {\n    const v = snap(next, min, max, step);\n    if (v === value) return;\n    setValue(v);\n    commit(v);\n  };\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const inc = e.shiftKey ? big : step;\n    const map: Record<string, number | undefined> = {\n      ArrowUp: value + inc,\n      ArrowRight: value + inc,\n      ArrowDown: value - inc,\n      ArrowLeft: value - inc,\n      PageUp: value + big,\n      PageDown: value - big,\n      Home: min,\n      End: max,\n      Delete: reset,\n      Backspace: reset,\n    };\n    const next = map[e.key];\n    if (next === undefined) return;\n    e.preventDefault();\n    nudge(next);\n  };\n\n  const onDoubleClick = () => {\n    if (!interactive || value === reset) return;\n    glide.current = true;\n    setValue(reset);\n    commit(reset);\n  };\n\n  const display = formatDisplay ? formatDisplay(value) : { value, suffix: unit };\n  const valueText =\n    getValueText?.(value) ??\n    (typeof display === \"string\"\n      ? display\n      : `${display.prefix ?? \"\"}${new Intl.NumberFormat(locale, format).format(display.value)}${display.suffix ?? \"\"}`.trim());\n  const readoutClass = cn(\n    \"tabular text-[12.5px] font-medium leading-[18px] tracking-[-0.005em] transition-colors duration-150\",\n    dragging ? \"text-fg\" : \"text-fg-2 group-focus-within/dial:text-fg\",\n  );\n\n  return (\n    <div\n      data-size={size}\n      data-disabled={disabled || undefined}\n      data-readonly={readOnly || undefined}\n      data-dragging={dragging || undefined}\n      className={cn(\"group/dial inline-flex w-max select-none flex-col items-center gap-2\", disabled && \"opacity-50\", className)}\n      {...rest}\n    >\n      <div className=\"relative grid place-items-center\" style={{ width: geo.box, height: geo.box }}>\n        <svg aria-hidden width={geo.box} height={geo.box} className=\"absolute inset-0 overflow-visible\" fill=\"none\" strokeLinecap=\"round\">\n          <path d={arc(c, r, START, -START)} strokeWidth={geo.stroke} className=\"stroke-line-2\" />\n          <motion.path d={fillPath} strokeWidth={geo.stroke} className={disabled ? \"stroke-fg-4\" : \"stroke-fg\"} />\n          {from > min && from < max && (\n            // The origin of a bipolar dial is printed just outside the ring, like hardware.\n            <path\n              d={`M ${polar(c, r + geo.stroke / 2 + 1.5, toAngle(from)).join(\" \")} L ${polar(c, r + geo.stroke / 2 + 4, toAngle(from)).join(\" \")}`}\n              strokeWidth={1.25}\n              className=\"stroke-fg-3\"\n            />\n          )}\n        </svg>\n\n        <div\n          ref={knobRef}\n          role=\"slider\"\n          tabIndex={disabled ? -1 : 0}\n          aria-labelledby={label ? labelId : undefined}\n          aria-label={label ? undefined : ariaLabel}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuenow={value}\n          aria-valuetext={valueText}\n          aria-orientation=\"vertical\"\n          aria-disabled={disabled || undefined}\n          aria-readonly={readOnly || undefined}\n          data-dragging={dragging || undefined}\n          suppressHydrationWarning\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={endDrag}\n          onPointerCancel={endDrag}\n          onLostPointerCapture={endDrag}\n          onKeyDown={onKeyDown}\n          onDoubleClick={onDoubleClick}\n          style={{ width: geo.knob, height: geo.knob }}\n          className={cn(\n            \"relative touch-none rounded-full border border-line-2 bg-raised shadow-[var(--shadow)]\",\n            \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n            \"transition-[scale,border-color] duration-150 ease-out motion-reduce:transition-none\",\n            // A 44px hit area even for the small knob.\n            \"before:absolute before:left-1/2 before:top-1/2 before:size-11 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full\",\n            interactive && (dragMode === \"circular\" ? \"cursor-grab data-[dragging]:cursor-grabbing\" : \"cursor-ns-resize\"),\n            interactive && \"hover:border-fg-4 data-[dragging]:scale-[0.96] data-[dragging]:border-fg-4 data-[dragging]:duration-75 motion-reduce:data-[dragging]:scale-100\",\n          )}\n        >\n          <motion.div aria-hidden className=\"absolute inset-0\" style={{ rotate }}>\n            <span\n              className={cn(\n                \"absolute left-1/2 top-[11%] h-[28%] w-[2px] -translate-x-1/2 rounded-full\",\n                disabled ? \"bg-fg-4\" : \"bg-fg\",\n              )}\n            />\n          </motion.div>\n        </div>\n      </div>\n\n      <div className=\"flex flex-col items-center\">\n        {typeof display === \"string\" ? (\n          <span aria-hidden className={readoutClass}>\n            {display}\n          </span>\n        ) : (\n          <NumberFlow\n            value={display.value}\n            prefix={display.prefix}\n            suffix={display.suffix}\n            format={format}\n            locales={locale}\n            // Digits roll for discrete changes (keys, wheel, reset). While dragging\n            // they follow the hand exactly, because a rolling readout lags the gesture.\n            animated={!dragging}\n            transformTiming={ROLL}\n            spinTiming={ROLL}\n            aria-hidden\n            className={readoutClass}\n          />\n        )}\n        {label && (\n          <span id={labelId} className=\"text-[11px] leading-[14px] text-fg-3\">\n            {label}\n          </span>\n        )}\n      </div>\n\n      {name && <input type=\"hidden\" name={name} value={value} disabled={disabled} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/dial"
}
