A compact angle input for gradients and rotations: drag anywhere on the dial to point the needle at the cursor with 15-degree snapping (Shift rotates freely), arrow keys step the value, and a synced numeric field stays in lockstep. Wraps cleanly at 360.
npx shadcn@latest add @paragon/angle-dial"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/** Wrap any angle into [0, 360) as an integer. */
function normalizeAngle(deg: number) {
return ((Math.round(deg) % 360) + 360) % 360;
}
/** Point on a circle where 0° points up 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;
}
export interface AngleDialProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Controlled angle in degrees — 0 points up, clockwise, wraps at 360. */
value?: number;
/** Initial angle when uncontrolled. Double-click returns to it. */
defaultValue?: number;
onValueChange?: (degrees: number) => void;
/** Drag snap increment in degrees. Hold Shift to rotate freely. 0 disables. */
snap?: number;
/** Dial diameter in px. */
size?: number;
/** Hide the synced numeric field for a dial-only footprint. */
withField?: boolean;
/** Form field name; renders a hidden input. */
name?: string;
disabled?: boolean;
/** Disables the dial's color transitions. */
static?: boolean;
}
/**
* A compact angle input for gradients and rotations: drag anywhere on the
* dial to point the needle at the cursor (pointer-captured; snaps to 15°,
* Shift rotates freely), arrow keys step ±1 or ±15 with Shift, and the
* numeric field stays in sync both ways. Wraps cleanly at 360.
*/
export function AngleDial({
value: valueProp,
defaultValue = 0,
onValueChange,
snap = 15,
size = 44,
withField = true,
name,
disabled = false,
static: isStatic = false,
className,
"aria-label": ariaLabel = "Angle",
...props
}: AngleDialProps) {
const [uncontrolled, setUncontrolled] = React.useState(() =>
normalizeAngle(defaultValue),
);
const value = normalizeAngle(valueProp ?? uncontrolled);
const valueRef = React.useRef(value);
valueRef.current = value;
const [dragging, setDragging] = React.useState(false);
const [draft, setDraft] = React.useState<string | null>(null);
const dialRef = React.useRef<HTMLDivElement>(null);
const setValue = React.useCallback(
(next: number) => {
const wrapped = normalizeAngle(next);
if (wrapped === valueRef.current) return;
if (valueProp === undefined) setUncontrolled(wrapped);
onValueChange?.(wrapped);
},
[valueProp, onValueChange],
);
// ---- Pointer: aim the needle at the cursor ----------------------------
const angleFromPointer = (event: React.PointerEvent) => {
const dial = dialRef.current;
if (!dial) return;
const rect = dial.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 < 0) deg += 360;
// Snapped by default; Shift rotates freely at 1° resolution.
if (snap > 0 && !event.shiftKey) deg = Math.round(deg / snap) * snap;
setValue(deg);
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
event.currentTarget.focus();
setDragging(true);
angleFromPointer(event);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
angleFromPointer(event);
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
};
// ---- Keyboard: ±1, Shift ±15 -------------------------------------------
const onKeyDown = (event: React.KeyboardEvent) => {
if (disabled) return;
const step = event.shiftKey ? 15 : 1;
const map: Record<string, number> = {
ArrowUp: step,
ArrowRight: step,
ArrowDown: -step,
ArrowLeft: -step,
PageUp: 45,
PageDown: -45,
};
if (event.key in map) {
event.preventDefault();
setValue(valueRef.current + map[event.key]);
} else if (event.key === "Home") {
event.preventDefault();
setValue(0);
} else if (event.key === "End") {
event.preventDefault();
setValue(180);
}
};
const commitDraft = () => {
if (draft !== null) {
const parsed = Number.parseFloat(draft);
if (!Number.isNaN(parsed)) setValue(parsed);
}
setDraft(null);
};
// ---- Geometry (fixed 44×44 viewBox space) ------------------------------
const ticks = React.useMemo(
() =>
Array.from({ length: 24 }, (_, i) => {
const deg = i * 15;
const major = deg % 90 === 0;
const [x1, y1] = polar(22, 22, major ? 17 : 18.25, deg);
const [x2, y2] = polar(22, 22, 20, deg);
return { deg, major, x1, y1, x2, y2 };
}),
[],
);
const [nx1, ny1] = polar(22, 22, 4, value);
const [nx2, ny2] = polar(22, 22, 13.5, value);
const transitions = isStatic
? ""
: "transition-[stroke,color,border-color] duration-150 ease-out";
return (
<div
data-slot="angle-dial"
className={cn(
"inline-flex items-center gap-2",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<div
ref={dialRef}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={359}
aria-valuetext={`${value}°`}
aria-disabled={disabled || undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
onDoubleClick={() => setValue(defaultValue)}
title="Drag to rotate — Shift for free rotation"
className={cn(
"relative shrink-0 cursor-crosshair 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",
// Interactive surface stays ≥ 40px even for small dials.
"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 viewBox="0 0 44 44" width={size} height={size} aria-hidden className="block">
{/* Tick ring — majors at the cardinals. */}
{ticks.map((tick) => (
<line
key={tick.deg}
x1={tick.x1}
y1={tick.y1}
x2={tick.x2}
y2={tick.y2}
strokeWidth={tick.major ? 1.5 : 1}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className={cn(
tick.major
? "stroke-muted-foreground/50"
: "stroke-muted-foreground/25",
transitions,
)}
/>
))}
{/* Face */}
<circle
cx={22}
cy={22}
r={14.5}
strokeWidth={1}
className={cn(
"fill-card stroke-border",
dragging && "stroke-ring/70",
transitions,
)}
/>
{/* Needle — 1:1 with the pointer, no lag. */}
<line
x1={nx1}
y1={ny1}
x2={nx2}
y2={ny2}
strokeWidth={2}
strokeLinecap="round"
className={cn(
dragging ? "stroke-primary" : "stroke-foreground",
transitions,
)}
/>
<circle
cx={22}
cy={22}
r={1.5}
className={cn(
dragging ? "fill-primary" : "fill-muted-foreground/60",
transitions,
)}
/>
</svg>
</div>
{withField && (
<div
className={cn(
"flex h-8 items-center rounded-md border border-input bg-transparent pr-2 pl-2.5",
"transition-[border-color,box-shadow] duration-150 ease-out",
"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
dragging && "border-ring ring-[3px] ring-ring/25",
)}
>
<input
type="text"
inputMode="numeric"
aria-label={`${ariaLabel} in degrees`}
value={draft ?? String(value)}
disabled={disabled}
onChange={(event) =>
setDraft(event.target.value.replace(/[^0-9.\-]/g, ""))
}
onFocus={() => setDraft(String(valueRef.current))}
onBlur={commitDraft}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitDraft();
event.currentTarget.blur();
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(null);
event.currentTarget.blur();
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
const step =
(event.key === "ArrowUp" ? 1 : -1) * (event.shiftKey ? 15 : 1);
setDraft(null);
setValue(valueRef.current + step);
}
}}
className="w-9 bg-transparent text-right text-sm font-medium text-foreground tabular-nums outline-none"
/>
<span aria-hidden className="ml-0.5 text-xs text-muted-foreground select-none">
°
</span>
</div>
)}
{name && <input type="hidden" name={name} value={String(value)} />}
</div>
);
}