Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
labelstringAccessible name.
valuenumberControlled value.
defaultValuenumber0Starting value when uncontrolled.
onValueChange(value: number) => voidCalled with the snapped value.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber5Snap size for pointer and keys.
sizenumber96Diameter 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.