A rotary dial input over a 270-degree sweep with arc or vertical drag (Shift for fine control), a tick ring that lights as the value passes, keyboard steps, and a tabular readout.
npx shadcn@latest add @paragon/knob"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
const START = -135; // degrees from 12 o'clock
const SWEEP = 270;
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v));
}
function decimalsOf(n: number) {
const s = String(n);
const i = s.indexOf(".");
return i === -1 ? 0 : s.length - i - 1;
}
function roundTo(v: number, decimals: number) {
const p = 10 ** decimals;
return Math.round(v * p) / p;
}
/** Point on a circle where 0° is 12 o'clock and angles grow clockwise. */
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = ((deg - 90) * Math.PI) / 180;
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)] as const;
}
function arcPath(cx: number, cy: number, r: number, from: number, to: number) {
const [x1, y1] = polar(cx, cy, r, from);
const [x2, y2] = polar(cx, cy, r, to);
const largeArc = to - from > 180 ? 1 : 0;
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${largeArc} 1 ${x2.toFixed(3)} ${y2.toFixed(3)}`;
}
export interface KnobProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Controlled value. */
value?: number;
/** Initial value when uncontrolled. Double-click returns to it. */
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/**
* "arc" points the knob at the cursor; "vertical" maps an up/down drag to
* the value (Shift for fine adjustment).
*/
dragMode?: "arc" | "vertical";
/** Rendered diameter in px. */
size?: number;
/** Label under the readout. */
label?: string;
/** Format the readout, e.g. `(v) => v.toFixed(2)`. */
formatValue?: (value: number) => string;
/** Unit appended to the readout and aria-valuetext. */
unit?: string;
/** Number of ticks around the dial. 0 hides the ring. */
ticks?: number;
/** Form field name; renders a hidden input. */
name?: string;
disabled?: boolean;
/** Disables the tick/readout color transitions. */
static?: boolean;
}
/**
* A rotary dial input over a 270° sweep: drag arcs the value toward the
* pointer (or up/down in vertical mode, Shift = fine), the tick ring lights
* as the arc passes, and arrow keys step the value — 1:1 with the pointer,
* no lag, like hardware. Double-click returns to the default.
*/
export function Knob({
value: valueProp,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
dragMode = "arc",
size = 72,
label,
formatValue,
unit,
ticks = 21,
name,
disabled = false,
static: isStatic = false,
className,
"aria-label": ariaLabel,
...props
}: KnobProps) {
const id = React.useId();
const [uncontrolled, setUncontrolled] = React.useState(
clamp(defaultValue, min, max),
);
const value = valueProp ?? uncontrolled;
const valueRef = React.useRef(value);
valueRef.current = value;
const [dragging, setDragging] = React.useState(false);
const svgRef = React.useRef<SVGSVGElement>(null);
const drag = React.useRef({ lastY: 0, acc: 0 });
const decimals = decimalsOf(step);
const setValue = React.useCallback(
(next: number) => {
const snapped = clamp(
roundTo(Math.round((next - min) / step) * step + min, decimals),
min,
max,
);
if (snapped === valueRef.current) return;
if (valueProp === undefined) setUncontrolled(snapped);
onValueChange?.(snapped);
},
[min, max, step, decimals, valueProp, onValueChange],
);
const t = max === min ? 0 : (value - min) / (max - min);
const angle = START + t * SWEEP;
// ---- Pointer ----------------------------------------------------------
const valueFromPointer = (event: React.PointerEvent) => {
const svg = svgRef.current;
if (!svg) return;
if (dragMode === "arc") {
const rect = svg.getBoundingClientRect();
const dx = event.clientX - (rect.left + rect.width / 2);
const dy = event.clientY - (rect.top + rect.height / 2);
let deg = (Math.atan2(dy, dx) * 180) / Math.PI + 90;
if (deg > 180) deg -= 360;
deg = clamp(deg, START, START + SWEEP);
setValue(min + ((deg - START) / SWEEP) * (max - min));
} else {
const dy = drag.current.lastY - event.clientY;
drag.current.lastY = event.clientY;
const fine = event.shiftKey ? 0.25 : 1;
drag.current.acc += (dy / 160) * (max - min) * fine;
drag.current.acc = clamp(drag.current.acc, min, max);
setValue(drag.current.acc);
}
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus();
drag.current = { lastY: event.clientY, acc: valueRef.current };
setDragging(true);
valueFromPointer(event);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
valueFromPointer(event);
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
};
// ---- Keyboard ---------------------------------------------------------
const onKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
const big = step * 10;
const mult = event.shiftKey ? 10 : 1;
const map: Record<string, number> = {
ArrowUp: step * mult,
ArrowRight: step * mult,
ArrowDown: -step * mult,
ArrowLeft: -step * mult,
PageUp: big,
PageDown: -big,
};
if (event.key in map) {
event.preventDefault();
setValue(valueRef.current + map[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setValue(min);
} else if (event.key === "End") {
event.preventDefault();
setValue(max);
}
};
const format = formatValue ?? ((v: number) => v.toFixed(decimals));
const readout = format(value);
// ---- Geometry (viewBox is a fixed 80×80 space) ------------------------
const tickMarks = React.useMemo(() => {
if (ticks <= 1) return [];
return Array.from({ length: ticks }, (_, i) => {
const deg = START + (i / (ticks - 1)) * SWEEP;
const [x1, y1] = polar(40, 40, 34, deg);
const [x2, y2] = polar(40, 40, 37.5, deg);
return { deg, x1, y1, x2, y2 };
});
}, [ticks]);
const transitions = isStatic
? ""
: "transition-[stroke,color] duration-150 ease-out";
return (
<div
data-slot="knob"
className={cn(
"inline-flex flex-col items-center gap-1.5",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuenow={value}
aria-valuemin={min}
aria-valuemax={max}
aria-valuetext={unit ? `${readout}${unit}` : readout}
aria-label={ariaLabel ?? label}
aria-disabled={disabled || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onDoubleClick={() => setValue(defaultValue)}
className={cn(
"relative touch-none rounded-full outline-none select-none",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
dragMode === "vertical" ? "cursor-ns-resize" : "cursor-pointer",
dragging && dragMode === "arc" && "cursor-grabbing",
// Keep the interactive surface ≥ 40px even for small knobs.
"after:absolute after:top-1/2 after:left-1/2 after:size-full after:min-h-10 after:min-w-10 after:-translate-1/2 after:rounded-full",
)}
style={{ width: size, height: size }}
>
<svg
ref={svgRef}
viewBox="0 0 80 80"
width={size}
height={size}
aria-hidden
className="block"
>
{/* Tick ring */}
{tickMarks.map((tick, i) => (
<line
key={i}
x1={tick.x1}
y1={tick.y1}
x2={tick.x2}
y2={tick.y2}
strokeWidth={1.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className={cn(
tick.deg <= angle + 0.001
? "stroke-primary"
: "stroke-muted-foreground/30",
transitions,
)}
/>
))}
{/* Track + value arc */}
<path
d={arcPath(40, 40, 30, START, START + SWEEP)}
fill="none"
strokeWidth={3}
strokeLinecap="round"
className="stroke-secondary"
/>
{value > min && (
<path
d={arcPath(40, 40, 30, START, Math.max(angle, START + 0.5))}
fill="none"
strokeWidth={3}
strokeLinecap="round"
className="stroke-primary"
/>
)}
{/* Body + indicator */}
<circle
cx={40}
cy={40}
r={24}
className={cn(
"fill-card stroke-border",
dragging && "stroke-ring/60",
transitions,
)}
strokeWidth={1}
/>
<g transform={`rotate(${angle} 40 40)`}>
<line
x1={40}
y1={19}
x2={40}
y2={26}
strokeWidth={2.5}
strokeLinecap="round"
className={cn(
dragging ? "stroke-primary" : "stroke-foreground",
transitions,
)}
/>
</g>
</svg>
</div>
<div className="flex flex-col items-center gap-px">
<span
className={cn(
"text-[13px] font-medium tabular-nums",
dragging ? "text-foreground" : "text-foreground/90",
transitions,
)}
>
{readout}
{unit && (
<span className="ml-0.5 text-[11px] font-normal text-muted-foreground">
{unit}
</span>
)}
</span>
{label && (
<span id={`${id}-label`} className="text-[11px] text-muted-foreground">
{label}
</span>
)}
</div>
{name && <input type="hidden" name={name} value={String(value)} />}
</div>
);
}