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