docs / core / knob

Knob

one number, set by turning: a level, a mix, a gain. it earns its circle when several sit in a row and get balanced against each other. for a single value in a settings list, Slider reads faster.

intensity · recedesrsc · client

provenance

authored in usva

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

banks of related values tuned against one anotherdrag the ring to dial it, or the body left and right to scrub. hold shift on either for fine controlpair label with showValue so the number survives the dragonValueCommitted to persist, onValueChange to previewnot a dial for one lonely setting. that is a Slidernot a readout. Progress and StatChip display, this one accepts

a11y

role="slider" named by its label · arrows step, Home and End reach the bounds, PageUp and PageDown move by ten, shift gives a tenth · the dial takes the focus ring, and the whole square is the hit target · no wheel handler, so scrolling the page over it stays scrolling the pagejest-axe

dependencies

class-variance-authority
live demo · try it out
Gain
6 dB
customize
labeltext above the dial
sizesm is for dense rows
minlower bound
-12
maxupper bound
12
stepincrement per tick
3
defaultValuestarting angle
6
suffixunit appended to the readout
showValuevalue readout under the dial
disableddims and ignores input
usagecopy
import { Knob } from "usva/primitives/knob";

<Knob
  label="Gain"
  defaultValue={6}
  min={-12}
  max={12}
  step={3}
  showValue
  formatValue={(v) => `${v} dB`}
/>

props

proptypedefaultnotes
valuenumbercontrolled value.
defaultValuenumberinitial value when uncontrolled.
onValueChange(value: number) => voidfires on every change, mid-drag and per key press.
onValueCommitted(value: number) => voidfires once the pointer or key is released. use this to persist.
minnumber0lower bound.
maxnumber100upper bound.
stepnumber1increment per arrow key press, and the grid every drag snaps to.
disabledbooleanfalsedims to 50% and ignores pointer and keyboard.
labelReactNodelabel above the dial. also names the knob for screen readers.
showValuebooleancurrent value under the dial, in mono with tabular figures.
formatValue(value: number) => stringformats the readout and the value announced to screen readers.
size"sm" | "md" | "lg""md"dial diameter, 44 / 64 / 96px.

get it

npx shadcn add https://usva.build/r/knob.jsoncopy
source · components/ui/knob-geometry.tsexactly what this command copiescopy
export const KNOB_SWEEP = 270;
export const KNOB_START_ANGLE = -135;

export const KNOB_DRAG_TRAVEL = 150;

const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
const clamp = (n: number, min: number, max: number) =>
  n < min ? min : n > max ? max : n;

export function valueToTurn(value: number, min: number, max: number): number {
  if (max === min) return 0;
  return clamp01((value - min) / (max - min));
}

export function turnToValue(turn: number, min: number, max: number): number {
  return min + clamp01(turn) * (max - min);
}

export function turnToAngle(turn: number): number {
  return KNOB_START_ANGLE + clamp01(turn) * KNOB_SWEEP;
}

export function arcLength(radius: number): number {
  return (2 * Math.PI * radius * KNOB_SWEEP) / 360;
}

export function dashForTurn(turn: number, radius: number): string {
  const length = arcLength(radius);
  return `${length * clamp01(turn)} ${length}`;
}

export function arcPath(radius: number, center: number): string {
  const at = (angle: number) => {
    const rad = (angle * Math.PI) / 180;
    return {
      x: center + radius * Math.sin(rad),
      y: center - radius * Math.cos(rad),
    };
  };
  const round = (n: number) => Math.round(n * 1000) / 1000;
  const start = at(KNOB_START_ANGLE);
  const end = at(KNOB_START_ANGLE + KNOB_SWEEP);
  const largeArc = KNOB_SWEEP > 180 ? 1 : 0;
  return `M ${round(start.x)} ${round(start.y)} A ${radius} ${radius} 0 ${largeArc} 1 ${round(end.x)} ${round(end.y)}`;
}

const decimalsOf = (step: number): number => {
  const text = String(step);
  if (text.includes("e")) return 12;
  const dot = text.indexOf(".");
  return dot === -1 ? 0 : text.length - dot - 1;
};

export function snapToStep(
  value: number,
  min: number,
  max: number,
  step: number,
): number {
  const bounded = clamp(value, min, max);
  if (!(step > 0)) return bounded;
  const snapped = min + Math.round((bounded - min) / step) * step;
  return clamp(
    Number(snapped.toFixed(Math.min(decimalsOf(step), 12))),
    min,
    max,
  );
}

export function stepValue(
  value: number,
  delta: number,
  min: number,
  max: number,
  step: number,
): number {
  return snapToStep(value + delta * step, min, max, step);
}

export function dragValue(
  startValue: number,
  dx: number,
  dy: number,
  min: number,
  max: number,
  step: number,
  fine = false,
): number {
  const travel = dx - dy;
  const moved = (travel / KNOB_DRAG_TRAVEL) * (max - min) * (fine ? 0.25 : 1);
  return snapToStep(startValue + moved, min, max, step);
}
source · components/ui/use-knob-value.tsexactly what this command copiescopy
"use client";
import * as React from "react";
import { dragValue, snapToStep, stepValue } from "./knob-geometry";

export interface UseKnobValueOptions {
  value?: number;
  defaultValue?: number;
  min: number;
  max: number;
  step: number;
  disabled?: boolean;
  onValueChange?: (value: number) => void;
  onValueCommitted?: (value: number) => void;
}

interface KeyIntent {
  min: number;
  max: number;
  step: number;
  shiftKey: boolean;
}

function nextForKey(
  key: string,
  value: number,
  { min, max, step, shiftKey }: KeyIntent,
): number | null {
  const move = shiftKey ? step / 10 : step;
  switch (key) {
    case "ArrowUp":
    case "ArrowRight":
      return stepValue(value, 1, min, max, move);
    case "ArrowDown":
    case "ArrowLeft":
      return stepValue(value, -1, min, max, move);
    case "PageUp":
      return stepValue(value, 10, min, max, move);
    case "PageDown":
      return stepValue(value, -10, min, max, move);
    case "Home":
      return min;
    case "End":
      return max;
    default:
      return null;
  }
}

export function useKnobValue({
  value,
  defaultValue,
  min,
  max,
  step,
  disabled,
  onValueChange,
  onValueCommitted,
}: UseKnobValueOptions) {
  const [uncontrolled, setUncontrolled] = React.useState(() =>
    snapToStep(defaultValue ?? min, min, max, step),
  );
  const [dragging, setDragging] = React.useState(false);

  const isControlled = value !== undefined;
  const current = isControlled
    ? snapToStep(value, min, max, step)
    : uncontrolled;

  const pendingRef = React.useRef(false);
  const dragRef = React.useRef({ x: 0, y: 0, value: 0 });

  const change = (next: number) => {
    if (next === current) return;
    if (!isControlled) setUncontrolled(next);
    onValueChange?.(next);
  };

  const onPointerDown = (event: React.PointerEvent<HTMLElement>) => {
    if (disabled || event.button !== 0) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    dragRef.current = { x: event.clientX, y: event.clientY, value: current };
    setDragging(true);
  };

  const onPointerMove = (event: React.PointerEvent<HTMLElement>) => {
    if (!dragging) return;
    const start = dragRef.current;
    change(
      dragValue(
        start.value,
        event.clientX - start.x,
        event.clientY - start.y,
        min,
        max,
        step,
        event.shiftKey,
      ),
    );
  };

  const endDrag = (event: React.PointerEvent<HTMLElement>) => {
    if (!dragging) return;
    if (event.currentTarget.hasPointerCapture?.(event.pointerId)) {
      event.currentTarget.releasePointerCapture(event.pointerId);
    }
    setDragging(false);
    onValueCommitted?.(current);
  };

  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    if (disabled) return;
    const next = nextForKey(event.key, current, {
      min,
      max,
      step,
      shiftKey: event.shiftKey,
    });
    if (next === null) return;
    event.preventDefault();
    pendingRef.current = true;
    change(next);
  };

  const onKeyUp = () => {
    if (!pendingRef.current) return;
    pendingRef.current = false;
    onValueCommitted?.(current);
  };

  return {
    value: current,
    dragging,
    handlers: {
      onPointerDown,
      onPointerMove,
      onPointerUp: endDrag,
      onPointerCancel: endDrag,
      onKeyDown,
      onKeyUp,
    },
  };
}
source · components/ui/knob.tsxexactly what this command copiescopy
"use client";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
  arcPath,
  dashForTurn,
  turnToAngle,
  valueToTurn,
} from "./knob-geometry";
import { useKnobValue } from "./use-knob-value";

const SIZES = {
  sm: { box: 44, stroke: 3, tick: 7, thickness: 2 },
  md: { box: 64, stroke: 4, tick: 10, thickness: 2.5 },
  lg: { box: 96, stroke: 6, tick: 14, thickness: 3 },
} as const;

const controlVariants = cva(
  cn(
    "group relative touch-none select-none rounded-full outline-none",
    "cursor-grab data-[dragging]:cursor-grabbing",
    "focus-visible:ring-focus",
    "data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50",
    "after:absolute after:inset-0 after:content-['']",
  ),
  {
    variants: { size: { sm: "", md: "", lg: "" } },
    defaultVariants: { size: "md" },
  },
);

export interface KnobProps
  extends Omit<
      React.HTMLAttributes<HTMLDivElement>,
      "defaultValue" | "onChange" | "children"
    >,
    VariantProps<typeof controlVariants> {
  value?: number;
  defaultValue?: number;
  onValueChange?: (value: number) => void;
  onValueCommitted?: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  label?: React.ReactNode;
  showValue?: boolean;
  formatValue?: (value: number) => string;
  disabled?: boolean;
}

export const Knob = React.forwardRef<HTMLDivElement, KnobProps>(
  (
    {
      className,
      size,
      value,
      defaultValue,
      onValueChange,
      onValueCommitted,
      min = 0,
      max = 100,
      step = 1,
      label,
      showValue,
      formatValue,
      disabled,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledBy,
      ...props
    },
    ref,
  ) => {
    const labelId = React.useId();
    const metrics = SIZES[size ?? "md"];
    const {
      value: current,
      dragging,
      handlers,
    } = useKnobValue({
      value,
      defaultValue,
      min,
      max,
      step,
      disabled,
      onValueChange,
      onValueCommitted,
    });

    const center = metrics.box / 2;
    const ringRadius = center - metrics.stroke / 2;
    const bodyRadius = ringRadius - metrics.stroke * 1.5;
    const anchorRadius = bodyRadius - metrics.tick;
    const head = metrics.stroke * 1.5;
    const thumbLength = dragging
      ? ringRadius + metrics.stroke * 0.75 - anchorRadius
      : metrics.tick;
    const turn = valueToTurn(current, min, max);
    const path = arcPath(ringRadius, center);
    const formatted = formatValue ? formatValue(current) : String(current);

    return (
      <div
        ref={ref}
        className={cn("inline-flex flex-col items-center gap-2", className)}
        {...props}
      >
        {label ? (
          <span
            id={labelId}
            className={cn(
              "text-sm text-ink select-none",
              disabled && "opacity-50",
            )}
          >
            {label}
          </span>
        ) : null}

        <div
          role="slider"
          tabIndex={disabled ? -1 : 0}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={current}
          aria-valuetext={formatted}
          aria-label={ariaLabel}
          aria-labelledby={label ? labelId : ariaLabelledBy}
          aria-disabled={disabled || undefined}
          data-dragging={dragging ? "" : undefined}
          data-disabled={disabled ? "" : undefined}
          className={controlVariants({ size })}
          style={{ width: metrics.box, height: metrics.box }}
          {...(disabled ? {} : handlers)}
        >
          <svg
            viewBox={`0 0 ${metrics.box} ${metrics.box}`}
            width={metrics.box}
            height={metrics.box}
            aria-hidden="true"
            focusable="false"
            className="block"
          >
            <path
              d={path}
              fill="none"
              strokeWidth={metrics.stroke}
              strokeLinecap="round"
              className="stroke-border-strong"
            />
            <path
              d={path}
              fill="none"
              strokeWidth={metrics.stroke}
              strokeLinecap="round"
              strokeDasharray={dashForTurn(turn, ringRadius)}
              className={cn(
                "stroke-accent",
                dragging
                  ? "transition-none"
                  : "transition-[stroke-dasharray] duration-fast ease-soft motion-reduce:transition-none",
              )}
            />
          </svg>

          <div
            className="absolute rounded-full bg-sunken shadow-raised"
            style={{ inset: center - bodyRadius }}
          />

          <div
            className={cn(
              "pointer-events-none absolute inset-0",
              dragging
                ? "transition-none"
                : "transition-transform duration-fast ease-soft motion-reduce:transition-none",
            )}
            style={{ transform: `rotate(${turnToAngle(turn)}deg)` }}
          >
            <div
              className="absolute left-1/2"
              style={{ top: center - anchorRadius }}
            >
              <div
                className={cn(
                  "absolute top-0 left-0 rounded-full bg-ink/70",
                  "transition-[width,height,background-color] duration-fast ease-soft motion-reduce:transition-none",
                  "group-hover:bg-ink",
                  "group-data-[dragging]:bg-ink/30",
                )}
                style={{
                  width: dragging ? head : metrics.thickness,
                  height: thumbLength,
                  transform: "translate(-50%, -100%)",
                }}
              >
                <span
                  className="absolute top-0 left-1/2 -translate-x-1/2 rounded-full bg-accent opacity-0 transition-opacity duration-fast ease-soft group-data-[dragging]:opacity-100 group-data-[dragging]:glow-accent-strong motion-reduce:transition-none"
                  style={{ width: head, height: head }}
                />
              </div>
            </div>
          </div>
        </div>

        {showValue ? (
          <span className="font-mono text-xs tabular-nums text-muted">
            {formatted}
          </span>
        ) : null}
      </div>
    );
  },
);

Knob.displayName = "Knob";