{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rotary-knob",
  "type": "registry:ui",
  "title": "Rotary Knob",
  "description": "A tactile synth-style dial: drag, turn, scroll or use the keyboard, with ticks, a value arc and optional detents.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/rotary-knob.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\n\nexport interface RotaryKnobProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"defaultValue\" | \"onChange\" | \"children\"> {\n  /** Visible label and accessible name. */\n  label: string;\n  /** Current value (controlled). */\n  value?: number;\n  /** Initial value (uncontrolled). Double-click resets to it. */\n  defaultValue?: number;\n  /** Called on every value change. */\n  onValueChange?: (value: number) => void;\n  /** Called when a drag ends, or after a key / wheel change. */\n  onValueCommit?: (value: number) => void;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Number of snap positions across the range (≥ 2). Overrides `step` and snaps with a spring. 0 = continuous. */\n  detents?: number;\n  /** Diameter of the dial in px. */\n  size?: number;\n  /** Number of tick marks around the dial (0 hides them). */\n  ticks?: number;\n  /** Drag up / down (and right / left), or turn around the centre. */\n  dragMode?: \"vertical\" | \"circular\";\n  /** Pixels of vertical drag that cover the whole range. */\n  sensitivity?: number;\n  /** Draw the value arc from the centre of the range (pan, balance, detune). */\n  bipolar?: boolean;\n  /** Show the formatted value under the label. */\n  showValue?: boolean;\n  /** Arc, active ticks and focus colour. */\n  accent?: string;\n  /** Turns a value into text for the readout and aria-valuetext. */\n  formatValue?: (value: number) => string;\n  /** Change the value with the scroll wheel while the pointer is over the dial. */\n  wheel?: boolean;\n  disabled?: boolean;\n  /** Submits the value with a form under this name. */\n  name?: string;\n}\n\nconst SWEEP = 135; // degrees either side of 12 o'clock\nconst clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));\n\n/** Point on a circle in the 100×100 viewBox; 0° is 12 o'clock, clockwise positive. */\nfunction polar(radius: number, degrees: number) {\n  const rad = (degrees * Math.PI) / 180;\n  return [50 + radius * Math.sin(rad), 50 - radius * Math.cos(rad)] as const;\n}\n\nfunction describeArc(radius: number, from: number, to: number) {\n  if (Math.abs(to - from) < 0.01) return \"\";\n  const [x0, y0] = polar(radius, from);\n  const [x1, y1] = polar(radius, to);\n  const large = Math.abs(to - from) > 180 ? 1 : 0;\n  const sweep = to > from ? 1 : 0;\n  return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${radius} ${radius} 0 ${large} ${sweep} ${x1.toFixed(3)} ${y1.toFixed(3)}`;\n}\n\n/**\n * A tactile dial for synth-style parameters. Drag vertically or around the centre, scroll, or use\n * the keyboard. The arc and the dial rotation are motion values, so dragging between steps never\n * re-renders; optional detents snap the dial with a subtle spring.\n */\nexport function RotaryKnob({\n  label,\n  value: valueProp,\n  defaultValue = 50,\n  onValueChange,\n  onValueCommit,\n  min = 0,\n  max = 100,\n  step = 1,\n  detents = 0,\n  size = 96,\n  ticks = 11,\n  dragMode = \"vertical\",\n  sensitivity = 200,\n  bipolar = false,\n  showValue = true,\n  accent = \"#FF7448\",\n  formatValue = (v) => String(Math.round(v * 100) / 100),\n  wheel = true,\n  disabled = false,\n  name,\n  className = \"\",\n  style,\n  onDoubleClick,\n  ...props\n}: RotaryKnobProps) {\n  const labelId = React.useId();\n  const reduceMotion = useReducedMotion();\n  const range = max - min || 1;\n  const snapping = detents >= 2;\n  const effectiveStep = snapping ? range / (detents - 1) : step;\n\n  const [internal, setInternal] = React.useState(() => clamp(defaultValue, min, max));\n  const value = clamp(valueProp ?? internal, min, max);\n  const [dragging, setDragging] = React.useState(false);\n\n  const toAngle = (v: number) => -SWEEP + ((v - min) / range) * SWEEP * 2;\n  const fromAngle = bipolar ? 0 : -SWEEP;\n\n  const angle = useMotionValue(toAngle(value));\n  const arc = useTransform(angle, (a) => describeArc(40, fromAngle, a));\n\n  const knobRef = React.useRef<HTMLDivElement>(null);\n  const drag = React.useRef<{ x: number; y: number; raw: number; pointerAngle: number; dialAngle: number } | null>(null);\n  const latest = React.useRef(value);\n  const wheelAccumulator = React.useRef(0);\n\n  const snap = (raw: number) => {\n    const stepped = Math.round((raw - min) / effectiveStep) * effectiveStep + min;\n    return clamp(Number(stepped.toFixed(6)), min, max);\n  };\n\n  const commitValue = (next: number) => {\n    latest.current = next;\n    if (next === value) return;\n    if (valueProp === undefined) setInternal(next);\n    onValueChange?.(next);\n  };\n\n  // Follow value changes (keyboard, wheel, detents, controlled updates) with a spring. During a\n  // continuous drag the dial is set directly from the pointer instead, so it is skipped then.\n  const targetAngle = toAngle(value);\n  React.useEffect(() => {\n    if (dragging && !snapping) return;\n    const controls = animate(\n      angle,\n      targetAngle,\n      reduceMotion\n        ? { duration: 0 }\n        : snapping\n          ? { type: \"spring\", stiffness: 640, damping: 22, mass: 0.7 }\n          : { type: \"spring\", stiffness: 380, damping: 34 },\n    );\n    return () => controls.stop();\n  }, [targetAngle, dragging, snapping, reduceMotion, angle]);\n\n  const pointerAngle = (clientX: number, clientY: number) => {\n    const rect = knobRef.current?.getBoundingClientRect();\n    if (!rect) return 0;\n    const dx = clientX - (rect.left + rect.width / 2);\n    const dy = clientY - (rect.top + rect.height / 2);\n    return (Math.atan2(dx, -dy) * 180) / Math.PI;\n  };\n\n  const updateFromPointer = (clientX: number, clientY: number) => {\n    const d = drag.current;\n    if (!d) return;\n    let raw: number;\n    if (dragMode === \"circular\") {\n      // Accumulate the change in pointer angle so crossing 6 o'clock never jumps.\n      const current = pointerAngle(clientX, clientY);\n      let delta = current - d.pointerAngle;\n      if (delta > 180) delta -= 360;\n      if (delta < -180) delta += 360;\n      d.pointerAngle = current;\n      d.dialAngle = clamp(d.dialAngle + delta, -SWEEP, SWEEP);\n      raw = min + ((d.dialAngle + SWEEP) / (SWEEP * 2)) * range;\n    } else {\n      // Up and right increase, down and left decrease.\n      const travel = clientX - d.x - (clientY - d.y);\n      raw = clamp(d.raw + (travel / Math.max(sensitivity, 1)) * range, min, max);\n    }\n    if (!snapping) angle.set(toAngle(raw));\n    commitValue(snap(raw));\n  };\n\n  const endDrag = () => {\n    if (!drag.current) return;\n    drag.current = null;\n    setDragging(false);\n    onValueCommit?.(latest.current);\n  };\n\n  const stepBy = (delta: number) => {\n    const next = snap(value + delta);\n    commitValue(next);\n    onValueCommit?.(next);\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return;\n    const big = Math.max(effectiveStep, snap(min + range / 10) - min);\n    switch (event.key) {\n      case \"ArrowUp\":\n      case \"ArrowRight\":\n        stepBy(effectiveStep);\n        break;\n      case \"ArrowDown\":\n      case \"ArrowLeft\":\n        stepBy(-effectiveStep);\n        break;\n      case \"PageUp\":\n        stepBy(big);\n        break;\n      case \"PageDown\":\n        stepBy(-big);\n        break;\n      case \"Home\":\n        stepBy(min - value);\n        break;\n      case \"End\":\n        stepBy(max - value);\n        break;\n      default:\n        return;\n    }\n    event.preventDefault();\n  };\n\n  // A non-passive native listener is required to stop the page from scrolling while turning.\n  const handleWheel = React.useEffectEvent((event: WheelEvent) => {\n    if (disabled || !wheel) return;\n    event.preventDefault();\n    const unit = event.deltaMode === 1 ? 33 : event.deltaMode === 2 ? 400 : 1;\n    wheelAccumulator.current += -(event.deltaY || -event.deltaX) * unit;\n    const notches = Math.trunc(wheelAccumulator.current / 50);\n    if (notches === 0) return;\n    wheelAccumulator.current -= notches * 50;\n    stepBy(notches * effectiveStep);\n  });\n\n  React.useEffect(() => {\n    const el = knobRef.current;\n    if (!el) return;\n    const listener = (event: WheelEvent) => handleWheel(event);\n    el.addEventListener(\"wheel\", listener, { passive: false });\n    return () => el.removeEventListener(\"wheel\", listener);\n  }, []);\n\n  const tickAngles = ticks >= 2 ? Array.from({ length: ticks }, (_, i) => -SWEEP + (i / (ticks - 1)) * SWEEP * 2) : [];\n  const lo = Math.min(fromAngle, targetAngle);\n  const hi = Math.max(fromAngle, targetAngle);\n  const text = formatValue(value);\n\n  return (\n    <div\n      className={`inline-flex select-none flex-col items-center gap-2.5 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n      style={{ [\"--rk-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <div\n        ref={knobRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-labelledby={labelId}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={value}\n        aria-valuetext={text}\n        aria-disabled={disabled || undefined}\n        data-dragging={dragging || undefined}\n        onKeyDown={handleKeyDown}\n        onPointerDown={(event) => {\n          if (disabled || event.button !== 0) return;\n          event.preventDefault();\n          event.currentTarget.setPointerCapture(event.pointerId);\n          event.currentTarget.focus({ preventScroll: true });\n          drag.current = {\n            x: event.clientX,\n            y: event.clientY,\n            raw: value,\n            pointerAngle: pointerAngle(event.clientX, event.clientY),\n            dialAngle: toAngle(value),\n          };\n          latest.current = value;\n          setDragging(true);\n        }}\n        onPointerMove={(event) => updateFromPointer(event.clientX, event.clientY)}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onLostPointerCapture={endDrag}\n        onDoubleClick={(event) => {\n          onDoubleClick?.(event);\n          if (!disabled) stepBy(clamp(defaultValue, min, max) - value);\n        }}\n        className={`relative shrink-0 touch-none rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[var(--rk-accent)] focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#131619] ${\n          disabled ? \"cursor-not-allowed\" : dragMode === \"vertical\" ? \"cursor-ns-resize\" : \"cursor-grab data-[dragging]:cursor-grabbing\"\n        }`}\n        style={{ width: size, height: size }}\n      >\n        <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full overflow-visible\" fill=\"none\" aria-hidden=\"true\">\n          {tickAngles.map((a, i) => {\n            const major = i === 0 || i === tickAngles.length - 1 || (bipolar && Math.abs(a) < 0.01);\n            const [x0, y0] = polar(49, a);\n            const [x1, y1] = polar(major ? 44.5 : 46, a);\n            const active = a >= lo - 0.01 && a <= hi + 0.01 && hi - lo > 0.01;\n            return (\n              <line\n                key={i}\n                x1={x0}\n                y1={y0}\n                x2={x1}\n                y2={y1}\n                strokeWidth={major ? 1.4 : 1}\n                strokeLinecap=\"round\"\n                className={`transition-[stroke] duration-150 ${active ? \"stroke-[var(--rk-accent)]\" : \"stroke-zinc-900/25 dark:stroke-white/25\"}`}\n              />\n            );\n          })}\n          <path d={describeArc(40, -SWEEP, SWEEP)} strokeWidth=\"3\" strokeLinecap=\"round\" className=\"stroke-zinc-900/[0.08] dark:stroke-white/[0.08]\" />\n          <motion.path d={arc} strokeWidth=\"3\" strokeLinecap=\"round\" stroke=\"var(--rk-accent)\" />\n        </svg>\n\n        {/* Dial body: rotates with the value; the notch shows the position. */}\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute inset-[21%] rounded-full border border-zinc-900/10 bg-[radial-gradient(circle_at_50%_30%,#ffffff,#e9e7e2)] shadow-[0_1px_0_rgb(255_255_255/0.9)_inset,0_6px_14px_-6px_rgb(0_0_0/0.35)] dark:border-white/10 dark:bg-[radial-gradient(circle_at_50%_30%,#2a2f35,#1a1d21)] dark:shadow-[0_1px_0_rgb(255_255_255/0.07)_inset,0_8px_18px_-8px_rgb(0_0_0/0.8)]\"\n          style={{ rotate: angle }}\n        >\n          {/* Fine knurling around the rim. */}\n          <span className=\"absolute inset-0 rounded-full bg-[repeating-conic-gradient(rgb(0_0_0/0.06)_0deg_2deg,transparent_2deg_7deg)] [mask:radial-gradient(circle,transparent_62%,#000_64%)] dark:bg-[repeating-conic-gradient(rgb(255_255_255/0.06)_0deg_2deg,transparent_2deg_7deg)]\" />\n          <span className=\"absolute left-1/2 top-[9%] h-[24%] w-[3px] -translate-x-1/2 rounded-full bg-[var(--rk-accent)]\" />\n        </motion.div>\n      </div>\n\n      <div className=\"flex flex-col items-center gap-0.5 text-center\">\n        <span id={labelId} className=\"font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400\">\n          {label}\n        </span>\n        {showValue && (\n          <span aria-hidden=\"true\" className=\"font-mono text-xs tabular-nums text-zinc-900 dark:text-zinc-100\">\n            {text}\n          </span>\n        )}\n      </div>\n\n      {name !== undefined && <input type=\"hidden\" name={name} value={value} />}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "buttons"
  ],
  "docs": "Documentation: https://wancraft.my/components/rotary-knob"
}
