Dial
A rotary slider that turns the pointer angle into a snapped value.
npx shadcn@latest add https://ui.goodtabs.co/r/dial.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/dial
A volume dial from 0 to 100 in steps of 5.
Showing the working
- θ = atan2(dx, -dy)
- value = round(((θ + 135°) / 270°) × 32 / 2) × 2
- Arrows step by 2, Page keys by 20
- Now: 12
A radius dial in steps of 2 with the formula beside it.
Usage
import { Dial } from "@/components/ui/dial"
<Dial label="Volume" defaultValue={60} step={5} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Accessible name. | |
| value | number | Controlled value. | |
| defaultValue | number | 0 | Starting value when uncontrolled. |
| onValueChange | (value: number) => void | Called with the snapped value. | |
| min | number | 0 | Lowest value. |
| max | number | 100 | Highest value. |
| step | number | 5 | Snap size for pointer and keys. |
| size | number | 96 | Diameter in px. |
Source
dial/dial.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type DialProps = Omit<React.ComponentProps<"div">, "onChange" | "role" | "defaultValue"> & {
/** Accessible name. */
label: string;
/** Controlled value. */
value?: number;
/** Starting value when uncontrolled. */
defaultValue?: number;
/** Called with the snapped value. */
onValueChange?: (value: number) => void;
min?: number;
max?: number;
/** Snap size. Pointer and arrow keys both land on multiples of it. */
step?: number;
/** Diameter in px. */
size?: number;
};
const SWEEP = 270;
/**
* A rotary slider. The pointer angle is atan2(dx, -dy), measured clockwise from the top, mapped
* across a 270 degree sweep and snapped to the step. It has the slider role and arrow keys.
*/
export function Dial({
label,
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 5,
size = 96,
className,
...props
}: DialProps) {
const [inner, setInner] = React.useState(defaultValue);
const current = value ?? inner;
const ref = React.useRef<HTMLDivElement>(null);
const snap = React.useCallback(
(v: number) => Math.min(max, Math.max(min, Math.round((v - min) / step) * step + min)),
[min, max, step],
);
function commit(v: number) {
const next = snap(v);
if (value === undefined) setInner(next);
if (next !== current) onValueChange?.(next);
}
function fromPointer(e: React.PointerEvent) {
const rect = ref.current!.getBoundingClientRect();
const dx = e.clientX - (rect.left + rect.width / 2);
const dy = e.clientY - (rect.top + rect.height / 2);
const theta = (Math.atan2(dx, -dy) * 180) / Math.PI; // -180..180, clockwise from top
const half = SWEEP / 2;
if (theta > half) return commit(max);
if (theta < -half) return commit(min);
commit(min + ((theta + half) / SWEEP) * (max - min));
}
function onKeyDown(e: React.KeyboardEvent) {
const big = step * 10;
const map: Record<string, number> = {
ArrowRight: current + step,
ArrowUp: current + step,
ArrowLeft: current - step,
ArrowDown: current - step,
PageUp: current + big,
PageDown: current - big,
Home: min,
End: max,
};
if (!(e.key in map)) return;
e.preventDefault();
commit(map[e.key]);
}
const stroke = 6;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const frac = (current - min) / (max - min);
const angle = -SWEEP / 2 + frac * SWEEP;
const stops = Math.round((max - min) / step);
return (
<div
ref={ref}
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
onKeyDown={onKeyDown}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
e.currentTarget.focus();
fromPointer(e);
}}
onPointerMove={(e) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) fromPointer(e);
}}
className={cn("relative cursor-grab touch-none rounded-full select-none active:cursor-grabbing", className)}
style={{ width: size, height: size }}
{...props}
>
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} aria-hidden="true">
<g transform={`rotate(135 ${size / 2} ${size / 2})`} fill="none" strokeWidth={stroke} strokeLinecap="round">
<circle
cx={size / 2}
cy={size / 2}
r={r}
strokeDasharray={`${c * 0.75} ${c}`}
className="stroke-foreground/12"
/>
<circle
cx={size / 2}
cy={size / 2}
r={r}
strokeDasharray={`${c * 0.75 * frac} ${c}`}
className="stroke-foreground transition-[stroke-dasharray] duration-100 ease-out motion-reduce:transition-none"
/>
</g>
</svg>
<div
aria-hidden="true"
className="absolute inset-[18%] rounded-full border bg-background shadow-xs"
style={{ transform: `rotate(${angle}deg)` }}
>
<span className="absolute top-1.5 left-1/2 h-2.5 w-0.5 -translate-x-1/2 rounded-full bg-foreground" />
</div>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0 grid place-items-center font-mono text-xs text-foreground tabular-nums"
>
{stops > 0 ? current : null}
</span>
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/dial/prompt.md, or the Copy AI prompt button above.
When to use
- Tuning a single value where the rotary gesture fits, like volume or hue
- Audio, camera and design tool controls
When not to use
- Forms where a plain slider or number field is faster
- Values that need exact entry, pair it with an input
Agent notes
- Client component with the real slider role. A demo that passes a function must be a client component
- Arrows step, Page keys move by ten steps, Home and End jump to the ends
- The sweep is 270 degrees with the gap at the bottom. Dragging past an end clamps to it
- Pointer capture keeps dragging alive outside the dial; touch-action is none on the dial only
The maths
- Pointer angle θ = atan2(dx, -dy) in degrees, clockwise from the top.
- The value is min + ((θ + 135) / 270) × (max - min), then snapped to the step.
- The arc is 270 / 360 = 0.75 of the circumference 2πr, drawn with dasharray 0.75 × 2πr × p.