Knob
Inputs & Forms

Knob

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.

Install

npx shadcn@latest add @paragon/knob

knob.tsx

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