New: Activity Terrain +12 more

Detent Dial

Rotary knob that clicks from notch to notch and coasts when flicked, built with React and Motion.

1 exampleDepends onmotionFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/detent-dial.json
Install manually
  1. Install the dependencies:

    npm install motion
  2. Copy this into components/kokonut/detent-dial.tsx:

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

Feedback

Questions, bugs or ideas for Detent Dial? Post them here, or reply to someone else. Sign in with GitHub to comment.