{
  "name": "detent-dial",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Detent Dial\n * @version: 1.0.0\n * @date: 2026-10-13\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  AnimatePresence,\n  type AnimationPlaybackControls,\n  animate,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type Variants,\n} from \"motion/react\";\nimport {\n  type KeyboardEvent,\n  type PointerEvent,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n// The dial sweeps 270°, from 7:30 to 4:30 on a clock face.\nconst SWEEP = 270;\nconst START_ANGLE = -135;\n// How far (deg) the knob can be wound past either end. The pull flattens\n// out as it approaches this, so it slows down instead of hitting a wall.\nconst MAX_OVERSHOOT = 16;\n// While dragging, the knob is pulled this much toward the nearest notch,\n// which is what makes it feel like it clicks from one step to the next.\nconst DETENT_PULL = 0.65;\nconst MAX_TICKS = 56;\n// The swell that follows the indicator round the ring: extra length (px)\n// at its peak, and how many degrees it spreads either side.\nconst WAVE_HEIGHT = 9;\nconst WAVE_WIDTH = 12;\n// Pointer movement this close to the centre gives wild angle jumps.\nconst DEAD_ZONE = 14;\n// Below this speed (fraction of the range per second) a release just\n// settles into the nearest notch instead of coasting.\nconst FLICK_THRESHOLD = 0.4;\nconst VELOCITY_WINDOW_MS = 80;\n// Seconds of travel a flick is worth: release speed × COAST = distance coasted.\nconst COAST = 0.3;\nconst SETTLE_SPRING = { type: \"spring\", duration: 0.35, bounce: 0.2 } as const;\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n\nfunction damp(distance: number, max: number) {\n  return max * (1 - Math.exp(-distance / max));\n}\n\nfunction clamp(value: number, min: number, max: number) {\n  return Math.min(Math.max(value, min), max);\n}\n\nfunction snap(value: number, min: number, step: number) {\n  return Number((Math.round((value - min) / step) * step + min).toFixed(10));\n}\n\nfunction polar(cx: number, cy: number, radius: number, angle: number) {\n  const rad = (angle * Math.PI) / 180;\n  // Rounded so the server and the browser, whose trig can differ in the\n  // last digits, render identical markup.\n  return {\n    x: Math.round((cx + radius * Math.sin(rad)) * 100) / 100,\n    y: Math.round((cy - radius * Math.cos(rad)) * 100) / 100,\n  };\n}\n\nfunction pointerAngle(event: PointerEvent, rect: DOMRect) {\n  const dx = event.clientX - (rect.left + rect.width / 2);\n  const dy = event.clientY - (rect.top + rect.height / 2);\n  return {\n    angle: (Math.atan2(dx, -dy) * 180) / Math.PI,\n    distance: Math.hypot(dx, dy),\n  };\n}\n\n// navigator.vibrate is a no-op where unsupported (iOS, desktop). On\n// Android it gives each notch a faint tick under the finger.\nfunction hapticTick() {\n  if (typeof navigator !== \"undefined\" && \"vibrate\" in navigator) {\n    navigator.vibrate(1);\n  }\n}\n\nexport interface DetentDialProps {\n  /** Accessible name, also shown inside the knob. */\n  label?: string;\n  min?: number;\n  max?: number;\n  step?: number;\n  /** Initial value when uncontrolled. Double-clicking the knob returns here. */\n  defaultValue?: number;\n  /** Controlled value. */\n  value?: number;\n  onValueChange?: (value: number) => void;\n  /** Formats the readout, the end labels and `aria-valuetext`. */\n  formatValue?: (value: number) => string;\n  /** Diameter in px. */\n  size?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\nexport default function DetentDial({\n  label = \"Temperature\",\n  min = 16,\n  max = 30,\n  step = 0.5,\n  defaultValue = 21,\n  value: valueProp,\n  onValueChange,\n  formatValue = (v) => `${v.toFixed(1)}°`,\n  size = 240,\n  disabled = false,\n  className,\n}: DetentDialProps) {\n  const labelId = useId();\n  const knobRef = useRef<HTMLDivElement>(null);\n  const tickRefs = useRef<(SVGLineElement | null)[]>([]);\n  // The length last written to each tick, so a frame only touches the few\n  // ticks the swell is actually passing over.\n  const tickLengths = useRef<number[]>([]);\n  const reduceMotion = useReducedMotion();\n  const range = max - min;\n\n  const initial = clamp(valueProp ?? defaultValue, min, max);\n  const [value, setValue] = useState(initial);\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const [grabbing, setGrabbing] = useState(false);\n  const committed = useRef(initial);\n  const pointerType = useRef(\"mouse\");\n\n  // The raw, unsnapped position in value units. It can run past min/max\n  // while winding; everything visual is derived from it.\n  const raw = useMotionValue(initial);\n  const animation = useRef<AnimationPlaybackControls | null>(null);\n  const drag = useRef<{\n    id: number;\n    // Measured once on grab: reading layout on every move forces the\n    // browser to recalculate it after each frame's writes.\n    rect: DOMRect;\n    lastAngle: number;\n    samples: { t: number; v: number }[];\n  } | null>(null);\n\n  const rotation = useTransform(raw, (r) => {\n    const degPerUnit = range === 0 ? 0 : SWEEP / range;\n    if (r < min) {\n      return START_ANGLE - damp((min - r) * degPerUnit, MAX_OVERSHOOT);\n    }\n    if (r > max) {\n      return START_ANGLE + SWEEP + damp((r - max) * degPerUnit, MAX_OVERSHOOT);\n    }\n    const notch = snap(r, min, step);\n    const pulled = notch + (r - notch) * (1 - DETENT_PULL);\n    return START_ANGLE + (pulled - min) * degPerUnit;\n  });\n\n  // Only re-render when the dial crosses into a new notch, not per frame.\n  useMotionValueEvent(raw, \"change\", (r) => {\n    const next = clamp(snap(r, min, step), min, max);\n    if (next === committed.current) {\n      return;\n    }\n    setDirection(next > committed.current ? 1 : -1);\n    committed.current = next;\n    setValue(next);\n    onValueChange?.(next);\n    if (pointerType.current === \"touch\") {\n      hapticTick();\n    }\n  });\n\n  // Follow a controlled value when the parent changes it.\n  useEffect(() => {\n    if (valueProp === undefined || drag.current) {\n      return;\n    }\n    const next = clamp(valueProp, min, max);\n    if (next !== committed.current) {\n      animation.current?.stop();\n      committed.current = next;\n      setValue(next);\n      raw.jump(next);\n    }\n  }, [valueProp, min, max, raw]);\n\n  useEffect(() => () => animation.current?.stop(), []);\n\n  const settleTo = (target: number) => {\n    animation.current?.stop();\n    animation.current = animate(\n      raw,\n      target,\n      reduceMotion ? { duration: 0 } : SETTLE_SPRING\n    );\n  };\n\n  // Keyboard and wheel steps jump straight to the value. They repeat fast,\n  // and an animation on each would trail behind the input.\n  const stepBy = (delta: number) => {\n    animation.current?.stop();\n    const target = clamp(snap(committed.current + delta, min, step), min, max);\n    pointerType.current = \"keyboard\";\n    raw.jump(target);\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (disabled || drag.current) {\n      return;\n    }\n    if (event.pointerType === \"mouse\" && event.button !== 0) {\n      return;\n    }\n    const rect = event.currentTarget.getBoundingClientRect();\n    const { angle } = pointerAngle(event, rect);\n    animation.current?.stop();\n    pointerType.current = event.pointerType;\n    event.currentTarget.setPointerCapture(event.pointerId);\n    drag.current = {\n      id: event.pointerId,\n      rect,\n      lastAngle: angle,\n      samples: [{ t: event.timeStamp, v: raw.get() }],\n    };\n    setGrabbing(true);\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const current = drag.current;\n    if (!current || current.id !== event.pointerId) {\n      return;\n    }\n    const { angle, distance } = pointerAngle(event, current.rect);\n    if (distance < DEAD_ZONE) {\n      current.lastAngle = angle;\n      return;\n    }\n    // Unwrap so crossing 6 o'clock doesn't read as a 360° jump.\n    let delta = angle - current.lastAngle;\n    if (delta > 180) {\n      delta -= 360;\n    } else if (delta < -180) {\n      delta += 360;\n    }\n    current.lastAngle = angle;\n    const next = raw.get() + (delta / SWEEP) * range;\n    raw.set(next);\n    current.samples.push({ t: event.timeStamp, v: next });\n    while (\n      current.samples.length > 2 &&\n      event.timeStamp - current.samples[0].t > VELOCITY_WINDOW_MS\n    ) {\n      current.samples.shift();\n    }\n  };\n\n  const handlePointerUp = (event: PointerEvent<HTMLDivElement>) => {\n    const current = drag.current;\n    if (!current || current.id !== event.pointerId) {\n      return;\n    }\n    drag.current = null;\n    setGrabbing(false);\n\n    const first = current.samples[0];\n    const last = current.samples.at(-1) ?? first;\n    const elapsed = (last.t - first.t) / 1000;\n    // Holding still before letting go is a placement, not a flick.\n    const paused = event.timeStamp - last.t > VELOCITY_WINDOW_MS;\n    const velocity = elapsed > 0 && !paused ? (last.v - first.v) / elapsed : 0;\n    const position = raw.get();\n    const inBounds = position >= min && position <= max;\n\n    if (\n      reduceMotion ||\n      !inBounds ||\n      Math.abs(velocity) < range * FLICK_THRESHOLD\n    ) {\n      settleTo(clamp(snap(position, min, step), min, max));\n      return;\n    }\n    // A flick coasts, slows down, and lands on a notch. If it runs out of\n    // room it bounces off the end instead of stopping dead.\n    animation.current?.stop();\n    // Project where the flick would coast to, land that on a notch, then\n    // spring there carrying the release velocity. If the projection runs\n    // past an end, the spring overshoots it a little and settles back.\n    const target = clamp(\n      snap(position + velocity * COAST, min, step),\n      min,\n      max\n    );\n    animation.current = animate(raw, target, {\n      type: \"spring\",\n      velocity,\n      stiffness: 140,\n      damping: 24,\n      restDelta: 0.005,\n    });\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) {\n      return;\n    }\n    const big = Math.max(step, snap(range / 10, 0, step));\n    const keySteps: Record<string, number> = {\n      ArrowUp: step,\n      ArrowRight: step,\n      ArrowDown: -step,\n      ArrowLeft: -step,\n      PageUp: big,\n      PageDown: -big,\n      Home: min - committed.current,\n      End: max - committed.current,\n    };\n    if (!(event.key in keySteps)) {\n      return;\n    }\n    event.preventDefault();\n    stepBy(keySteps[event.key]);\n  };\n\n  // The wheel only turns the dial once it has focus, so scrolling the page\n  // past it never changes the value by accident. The listener is attached\n  // once and reads the latest props through a ref.\n  const wheelStep = useRef({ disabled, step, stepBy });\n  wheelStep.current = { disabled, step, stepBy };\n  useEffect(() => {\n    const knob = knobRef.current;\n    if (!knob) {\n      return;\n    }\n    let accumulated = 0;\n    const onWheel = (event: WheelEvent) => {\n      const latest = wheelStep.current;\n      if (latest.disabled || document.activeElement !== knob) {\n        return;\n      }\n      event.preventDefault();\n      accumulated += event.deltaY;\n      // Trackpads send many tiny deltas; one notch per ~40px of scroll.\n      while (Math.abs(accumulated) >= 40) {\n        const sign = Math.sign(accumulated);\n        accumulated -= sign * 40;\n        latest.stepBy(-sign * latest.step);\n      }\n    };\n    knob.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => knob.removeEventListener(\"wheel\", onWheel);\n  }, []);\n\n  const center = size / 2;\n  const tickCount = Math.max(1, Math.min(Math.round(range / step), MAX_TICKS));\n  const valueFraction = range === 0 ? 0 : (value - min) / range;\n  const activeTick = Math.round(valueFraction * tickCount);\n  const knobSize = size * 0.64;\n  const outer = size / 2 - 6;\n\n  // Ticks swell as the indicator passes them, following its exact angle\n  // (not the snapped value), so a wave travels round the ring as you turn.\n  function tickLength(i: number, indicatorAngle: number) {\n    const angle = START_ANGLE + (i / tickCount) * SWEEP;\n    // Min, mid and max read as the scale's anchors, like a ruler's inch marks.\n    const anchor = i === 0 || i === tickCount || i * 2 === tickCount;\n    const major = i % 5 === 0;\n    let base = 6;\n    if (anchor) {\n      base = 14;\n    } else if (major) {\n      base = 10;\n    }\n    const distance = (angle - indicatorAngle) / WAVE_WIDTH;\n    // Rounded to a tenth of a pixel: finer changes aren't visible, and\n    // skipping them leaves most ticks untouched on any given frame.\n    return (\n      Math.round((base + WAVE_HEIGHT * Math.exp(-distance * distance)) * 10) /\n      10\n    );\n  }\n\n  function tickGeometry(i: number, length: number) {\n    const angle = START_ANGLE + (i / tickCount) * SWEEP;\n    return {\n      a: polar(center, center, outer - length, angle),\n      b: polar(center, center, outer, angle),\n    };\n  }\n\n  // Written straight to the lines each frame; no re-render while turning.\n  useMotionValueEvent(rotation, \"change\", (indicatorAngle) => {\n    for (let i = 0; i <= tickCount; i++) {\n      const line = tickRefs.current[i];\n      const length = tickLength(i, indicatorAngle);\n      if (!line || tickLengths.current[i] === length) {\n        continue;\n      }\n      tickLengths.current[i] = length;\n      const { a } = tickGeometry(i, length);\n      line.setAttribute(\"x1\", String(a.x));\n      line.setAttribute(\"y1\", String(a.y));\n    }\n  });\n  const minLabel = polar(center, center, outer + 2, START_ANGLE - 12);\n  const maxLabel = polar(center, center, outer + 2, START_ANGLE + SWEEP + 12);\n\n  return (\n    <div\n      className={cn(\n        \"relative select-none\",\n        disabled && \"opacity-50\",\n        className\n      )}\n      style={{ width: size, height: size }}\n    >\n      <svg\n        aria-hidden=\"true\"\n        className=\"absolute inset-0\"\n        height={size}\n        viewBox={`0 0 ${size} ${size}`}\n        width={size}\n      >\n        {Array.from({ length: tickCount + 1 }, (_, i) => {\n          const lit = i <= activeTick;\n          const current = i === activeTick;\n          const length = tickLength(i, rotation.get());\n          tickLengths.current[i] = length;\n          const { a, b } = tickGeometry(i, length);\n          return (\n            <line\n              className={cn(\n                \"transition-[stroke] duration-150\",\n                current && \"stroke-orange-600 dark:stroke-orange-400\",\n                !current &&\n                  (lit\n                    ? \"stroke-zinc-900 dark:stroke-zinc-100\"\n                    : \"stroke-zinc-300 dark:stroke-zinc-700\")\n              )}\n              // biome-ignore lint/suspicious/noArrayIndexKey: ticks are positional.\n              key={i}\n              ref={(node) => {\n                tickRefs.current[i] = node;\n              }}\n              strokeLinecap=\"round\"\n              strokeWidth={current ? 2 : 1.5}\n              x1={a.x}\n              x2={b.x}\n              y1={a.y}\n              y2={b.y}\n            />\n          );\n        })}\n      </svg>\n\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute -translate-x-1/2 text-[11px] text-zinc-400 tabular-nums\"\n        style={{ left: minLabel.x, top: minLabel.y }}\n      >\n        {formatValue(min)}\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className=\"absolute -translate-x-1/2 text-[11px] text-zinc-400 tabular-nums\"\n        style={{ left: maxLabel.x, top: maxLabel.y }}\n      >\n        {formatValue(max)}\n      </span>\n\n      <div\n        aria-disabled={disabled || undefined}\n        aria-labelledby={labelId}\n        aria-valuemax={max}\n        aria-valuemin={min}\n        aria-valuenow={value}\n        aria-valuetext={formatValue(value)}\n        className={cn(\n          \"absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 touch-none rounded-full outline-none\",\n          \"border border-zinc-200 bg-white shadow-[0_1px_2px_rgb(0_0_0/0.06),0_12px_32px_-12px_rgb(0_0_0/0.25)] dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-[0_1px_2px_rgb(0_0_0/0.4),0_12px_32px_-12px_rgb(0_0_0/0.8)]\",\n          \"transition-[border-color] duration-150 focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950\",\n          grabbing\n            ? \"cursor-grabbing border-zinc-300 dark:border-zinc-700\"\n            : \"cursor-grab\",\n          disabled && \"cursor-not-allowed\"\n        )}\n        onDoubleClick={() => {\n          if (!disabled) {\n            pointerType.current = \"mouse\";\n            settleTo(clamp(defaultValue, min, max));\n          }\n        }}\n        onKeyDown={handleKeyDown}\n        onLostPointerCapture={handlePointerUp}\n        onPointerCancel={handlePointerUp}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={handlePointerUp}\n        ref={knobRef}\n        role=\"slider\"\n        style={{ width: knobSize, height: knobSize }}\n        tabIndex={disabled ? -1 : 0}\n      >\n        {/* Only the indicator turns; the readout stays upright. */}\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"absolute inset-0\"\n          style={{ rotate: rotation }}\n        >\n          <span className=\"absolute top-2.5 left-1/2 h-3 w-[3px] -translate-x-1/2 rounded-full bg-zinc-900 dark:bg-zinc-100\" />\n        </motion.div>\n\n        <div className=\"pointer-events-none absolute inset-0 flex flex-col items-center justify-center\">\n          <span\n            className=\"text-xs text-zinc-500 dark:text-zinc-400\"\n            id={labelId}\n          >\n            {label}\n          </span>\n          <RollingValue\n            direction={direction}\n            reduceMotion={Boolean(reduceMotion)}\n            text={formatValue(value)}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n\n/**\n * Each character rolls on its own, so going from 21.5 to 22.0 only moves\n * the digits that changed. A touch of blur hides the overlap between the\n * outgoing and incoming glyph.\n */\n// `custom` is the signed offset of the latest change. AnimatePresence hands\n// the current value to the leaving glyph too, so it always exits opposite to\n// the one coming in.\nconst rollVariants: Variants = {\n  // A full transform string rather than `y`, so Motion can hand the roll to\n  // the compositor instead of stepping it on the main thread.\n  enter: (offset: number) => ({\n    opacity: 0,\n    transform: `translateY(${offset}px)`,\n    filter: offset === 0 ? \"blur(0px)\" : \"blur(2px)\",\n  }),\n  center: { opacity: 1, transform: \"translateY(0px)\", filter: \"blur(0px)\" },\n  exit: (offset: number) => ({\n    opacity: 0,\n    transform: `translateY(${-offset}px)`,\n    filter: offset === 0 ? \"blur(0px)\" : \"blur(2px)\",\n  }),\n};\n\nfunction RollingValue({\n  text,\n  direction,\n  reduceMotion,\n}: {\n  text: string;\n  direction: 1 | -1;\n  reduceMotion: boolean;\n}) {\n  const offset = reduceMotion ? 0 : direction * 10;\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"relative flex font-medium text-3xl text-zinc-900 tabular-nums tracking-tight dark:text-zinc-100\"\n    >\n      {text.split(\"\").map((char, index) => (\n        <span\n          className=\"relative inline-flex overflow-hidden\"\n          // Position is the identity: the same slot keeps its node.\n          // biome-ignore lint/suspicious/noArrayIndexKey: characters are positional.\n          key={index}\n        >\n          <AnimatePresence custom={offset} initial={false} mode=\"popLayout\">\n            <motion.span\n              animate=\"center\"\n              custom={offset}\n              exit=\"exit\"\n              initial=\"enter\"\n              key={char}\n              transition={{ duration: 0.16, ease: EASE_OUT }}\n              variants={rollVariants}\n            >\n              {char}\n            </motion.span>\n          </AnimatePresence>\n        </span>\n      ))}\n    </span>\n  );\n}\n",
      "path": "/components/kokonutui/detent-dial.tsx",
      "target": "components/kokonutui/detent-dial.tsx"
    }
  ]
}