Angle Dial
Inputs & Forms

Angle Dial

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.

Install

npx shadcn@latest add @paragon/angle-dial

angle-dial.tsx

"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>
  );
}