# Knob

> A rotary control for continuous parameters, with pointer, wheel and keyboard input.

```tsx
import { Knob } from "@/components/audio-ui/knob";

const MARKS = [
  { label: "0", value: 0 },
  25,
  50,
  75,
  { label: "100", value: 100 },
];

export function KnobDemo() {
  return (
    <Knob
      aria-label="Level"
      defaultValue={50}
      editableValue
      marks={MARKS}
      max={100}
      min={0}
      size="lg"
      step={1}
    />
  );
}
```

A rotary control for continuous parameters such as level, cutoff or pan. The registry component wraps the `Knob` primitive from `@audio-ui/react` and adds size variants and theme styles.

## Overview

- **Input:** drag around the knob (`dragMode="circular"`, the default), up and down (`"vertical"`), or by full turns (`"revolution"`). The mouse wheel and the keyboard also work. A double-click or a double-tap returns to `defaultValue`.
- **Display:** a track, an arc from `anchor` to the value, and a needle that turns with the value. The needle follows the pointer without easing, so it stays in line with the arc.
- **Accessibility:** the knob holds a native `<input type="range">` that takes focus and carries the accessible name (`aria-label` or `aria-labelledby`), the value text (formatted with `format`, or set with `getAriaValueText` on the primitive's `Thumb`) and the form value (`name`).

## Installation

**CLI**

```bash
npx shadcn@latest add @audio-ui/knob
```

**Manual**

**1. Install the following dependencies:**

```bash
npm install @audio-ui/react class-variance-authority
```

**2. Copy and paste the following code into your project.**

```tsx title="components/audio-ui/knob.tsx"
"use client";

import { Knob as KnobPrimitive } from "@audio-ui/react";
import { cva, type VariantProps } from "class-variance-authority";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";

const knobVariants = cva(
  "rounded-full border border-transparent ring-1 ring-foreground/10 transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 hover:ring-2 hover:ring-ring/40 has-[[data-slot=knob-control]:active]:border-ring has-[[data-slot=knob-control]:active]:ring-3 has-[[data-slot=knob-control]:active]:ring-ring motion-reduce:transition-none relative inline-flex outline-none data-disabled:cursor-not-allowed",
  {
    defaultVariants: {
      size: "default",
    },
    variants: {
      size: {
        default: "size-12",
        lg: "size-16",
        sm: "size-11",
        xl: "size-24",
      },
    },
  }
);

const knobControlClassName =
  "bg-secondary text-secondary-foreground shadow-xs transition-[color,background-color,box-shadow,border-color,opacity] duration-150 ease-out hover:bg-secondary/90 active:shadow-[inset_0_-14px_28px_-12px_rgb(0_0_0/0.14)] motion-reduce:transition-none dark:bg-secondary/60 dark:hover:bg-secondary/70 dark:active:shadow-[inset_0_-14px_28px_-12px_rgb(0_0_0/0.28)] transform-[translateZ(0)] relative isolate flex size-full cursor-grab touch-none select-none outline-none [clip-path:circle(50%_at_50%_50%)] focus-visible:outline-none active:cursor-grabbing data-disabled:pointer-events-none data-disabled:opacity-50";

const knobBodyVariants = cva("rounded-full bg-card text-card-foreground ring-1 ring-foreground/10 transition-transform duration-100 ease-out motion-reduce:transition-none absolute z-0", {
  defaultVariants: {
    size: "default",
  },
  variants: {
    size: {
      default: "inset-1.5",
      lg: "inset-2",
      sm: "inset-1",
      xl: "inset-2.5",
    },
  },
});

/** A graduation around the dial: a value, or a value and its label. */
export type KnobMark = number | { label?: ReactNode; value: number };

export type KnobProps = KnobPrimitive.Root.Props &
  VariantProps<typeof knobVariants> & {
    /** Prints the value in the readout and in what screen readers announce, e.g. as `"8.7 kHz"`. Defaults to `format`/`locale`. */
    formatValue?: (value: number) => string;
    /** Graduations drawn around the dial, on its scale. */
    marks?: readonly KnobMark[];
    /** Lets you type the value: a double click on the readout, or Enter while it has focus. */
    editableValue?: boolean;
    /** When the value shows under the dial: while hovered, turned or focused (`auto`), `always`, or `hidden`. */
    valueLabel?: "always" | "auto" | "hidden";
  };

const markValue = (mark: KnobMark) =>
  typeof mark === "number" ? mark : mark.value;
const markLabel = (mark: KnobMark) =>
  typeof mark === "number" ? undefined : mark.label;

function Knob({
  "aria-describedby": ariaDescribedBy,
  "aria-label": ariaLabel,
  "aria-labelledby": ariaLabelledBy,
  className,
  editableValue = false,
  formatValue,
  marks,
  size,
  valueLabel = "auto",
  ...props
}: KnobProps) {
  const hasLabels = marks?.some((mark) => markLabel(mark) !== undefined);
  return (
    <KnobPrimitive.Root
      className={cn(
        knobVariants({ size }),
        "group/knob",
        marks && "m-2",
        hasLabels && "m-5",
        // The readout hangs under the dial: leave it room, or a panel clips it.
        valueLabel !== "hidden" && (hasLabels ? "mb-12" : "mb-8"),
        className
      )}
      data-slot="knob"
      {...props}
    >
      <KnobPrimitive.Control
        className={knobControlClassName}
        data-slot="knob-control"
      >
        <div className={knobBodyVariants({ size })} data-slot="knob-body" />
        <KnobPrimitive.Track
          className="text-primary [&_path]:stroke-current pointer-events-none absolute inset-0 z-1 block size-full opacity-22"
          data-slot="knob-track"
        />
        <KnobPrimitive.Indicator
          className="text-primary [&_path]:stroke-current pointer-events-none absolute inset-0 z-1 block size-full"
          data-slot="knob-arc"
        />
        <KnobPrimitive.Thumb
          aria-describedby={ariaDescribedBy}
          aria-label={ariaLabel}
          aria-labelledby={ariaLabelledBy}
          className="pointer-events-none absolute inset-0 z-2"
          data-slot="knob-thumb"
          getAriaValueText={
            formatValue ? (_formatted, value) => formatValue(value) : undefined
          }
        >
          <svg
            aria-hidden="true"
            className="text-primary block size-full"
            viewBox="0 0 48 48"
          >
            <line
              stroke="currentColor"
              strokeLinecap="round"
              strokeWidth={2.75}
              x1={24}
              x2={24}
              y1={11.53}
              y2={18.84}
            />
          </svg>
        </KnobPrimitive.Thumb>
      </KnobPrimitive.Control>
      {marks?.map((mark) => (
        <KnobPrimitive.Mark key={markValue(mark)} value={markValue(mark)}>
          <span className="bg-muted-foreground/40 transition-colors duration-150 ease-out in-data-[active]:data-[color]:bg-primary motion-reduce:transition-none -top-2.5 absolute left-1/2 h-1.5 w-px -translate-x-1/2" />
          {markLabel(mark) === undefined ? null : (
            <span className="text-[10px] leading-none text-muted-foreground tabular-nums in-data-[active]:text-foreground -top-6 absolute left-1/2 -translate-x-1/2 [rotate:calc(var(--knob-mark-angle)*-1)]">
              {markLabel(mark)}
            </span>
          )}
        </KnobPrimitive.Mark>
      ))}
      {valueLabel === "hidden" ? null : (
        <KnobPrimitive.Value
          className={cn(
            "rounded-[min(var(--radius-sm),6px)] bg-foreground px-1.5 py-0.5 font-medium text-[10px] text-background tabular-nums leading-none shadow-sm absolute top-full left-1/2 z-10 mt-1 -translate-x-1/2 whitespace-nowrap",
            editableValue ? "cursor-text" : "pointer-events-none",
            // Below the labels at the ends of the sweep, not over them.
            hasLabels && "mt-6",
            valueLabel === "auto" &&
              "opacity-0 transition-opacity duration-150 ease-out group-hover/knob:opacity-100 group-has-focus-visible/knob:opacity-100 group-data-dragging/knob:opacity-100 data-editing:opacity-100 motion-reduce:transition-none"
          )}
          data-slot="knob-value"
          editable={editableValue}
        >
          {(formatted, value) => (formatValue ? formatValue(value) : formatted)}
        </KnobPrimitive.Value>
      )}
    </KnobPrimitive.Root>
  );
}

export { Knob, knobVariants };
```

**3. Update the import paths to match your project setup.**

## Anatomy

Import the primitive and assemble its parts.

```tsx
import { Knob } from "@audio-ui/react";

<Knob.Root>
  <Knob.Control>
    <Knob.Track />
    <Knob.Indicator />
    <Knob.Thumb />
    <Knob.Mark value={50} />
  </Knob.Control>
  <Knob.Value />
  <Knob.Label />
</Knob.Root>
```

## Usage

```tsx
import { Knob } from "@/components/audio-ui/knob";
```

**Uncontrolled:** set only the initial value.

```tsx
<Knob defaultValue={50} max={100} min={0} step={1} />
```

**Controlled:** drive the value from state.

```tsx
const [cutoff, setCutoff] = useState(1000);

<Knob
  max={20_000}
  min={20}
  onValueChange={setCutoff}
  step={1}
  value={cutoff}
/>
```

Pass `defaultValue` even when the knob is controlled if you use the double-tap reset: it sets the value the knob returns to, clamped and stepped.

## Interaction

### Pointer

**Behavior**

| Gesture | Effect |
| --- | --- |
| Circular drag (default) | The value follows the rotation around the center, from the value at pointer down. The dial's 288° sweep spans `min…max`. |
| Vertical drag | `dragMode="vertical"`: drag up to increase, like a fader; 150px of travel spans `min…max`. |
| Revolution drag | `dragMode="revolution"`: one full 360° turn spans `min…max`. |
| Fine drag | Hold `Shift` while dragging, in any mode, to turn four times slower. Only the stretch moved with `Shift` held is slowed, and small moves add up rather than rounding away. |
| Double-tap / double-click | Two quick taps (each moving ≤ ~12px, within ~320ms) reset to `defaultValue`, or to the middle of the dial's travel without one. Reports `eventDetails.reason === "reset"`. |

Values stay within `min…max` and do not wrap. While dragging, the knob follows the pointer even if a controlled `value` updates late.

### Scale

`scale` sets how the value maps onto the dial's travel. `"linear"` (the default) suits levels and times; `"log"` suits frequency, resonance and ratios, which are heard as proportions: on a 20 Hz–20 kHz cutoff, every decade gets the same share of the dial, and 632 Hz sits at the middle. A log scale needs a positive `min`. For anything else, pass your own `{ toPosition, toValue }`, mapping a value to 0…1 along the travel and back.

```tsx
<Knob aria-label="Cutoff" max={20_000} min={20} scale="log" />
```

Away from linear, the keys and the wheel move a share of the travel (1%, or 10% with `Shift` and the Page keys) rather than a `step`, and always by at least one `step`.

### Keyboard

On the focused knob:

**Keys**

| Key | Effect |
| --- | --- |
| `ArrowUp` / `ArrowRight` | Increase by `step` |
| `ArrowDown` / `ArrowLeft` | Decrease by `step` |
| `PageUp` / `Shift` + `ArrowUp` | Increase by `largeStep` (default `10`) |
| `PageDown` / `Shift` + `ArrowDown` | Decrease by `largeStep` (default `10`) |
| `Home` | Set to `min` |
| `End` | Set to `max` |

### Wheel

The mouse wheel over the knob moves the value by `step` and reports `eventDetails.reason === "wheel"`.

## API reference

### Root

Groups all parts of the knob and owns its value. Dragging the `Control`, turning the mouse wheel over it, the keyboard on the `Thumb`'s input, or a double tap (reset) all report through `onValueChange` / `onValueCommitted`. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `anchor` | `number` |  | Value the indicator arc grows from (e.g. `0` on a -1…1 pan knob). Defaults to `min`. |
| `defaultValue` | `number` |  | Uncontrolled starting value, and the reset target of a double tap, an Alt + click and the Delete key. Defaults to the middle of the dial's travel. |
| `disabled` | `boolean` |  |  |
| `dragMode` | `DragMode` | `"circular"` |  |
| `form` | `string` |  | Identifies the form that owns the knob's input, when rendered outside it. |
| `format` | `Intl.NumberFormatOptions` |  | Formats the value for `Knob.Value` and the input's `aria-valuetext`. |
| `largeStep` | `number` | `10` | Step for Page Up/Page Down and Shift + Arrow keys. |
| `locale` | `Intl.LocalesArgument` |  |  |
| `max` | `number` | `100` |  |
| `min` | `number` | `0` |  |
| `name` | `string` |  | Identifies the field when a form is submitted. |
| `onValueChange` | `(value: number, eventDetails: ChangeEventDetails) => void` |  | Fires on every change: drag, keyboard, wheel, reset and input changes. |
| `onValueCommitted` | `(value: number, eventDetails: CommitEventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `scale` | `Scale` | `"linear"` | How the value maps onto the dial's travel: `linear`, `log` for frequency, Q or ratios (needs a positive `min`), or your own `{ toPosition, toValue }`. Away from linear, the keys and the wheel move a share of the travel (1%, or 10% with Shift and Page keys) rather than a `step`. |
| `step` | `number` | `1` |  |
| `value` | `number` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Control

The interactive surface: pointer drags (see `dragMode`), mouse-wheel stepping, and double tap to reset. Sets `touch-action: none` so touch drags don't scroll the page. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Track

The full sweep of the dial, as an SVG arc. Renders a `<svg>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `strokeWidth` | `number` | `3` | Stroke width in the 48×48 viewBox; the arc stays inside the box. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Indicator

The arc from `anchor` to the value, drawn over the `Track`. Renders a `<svg>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Thumb

Rotates with the value (`rotate`, clockwise from 12 o'clock), so a pointer drawn inside it points at the value. Holds the knob's native `<input type="range">`, which takes focus, carries the accessible name (`aria-label` / `aria-labelledby`) and the form value, and handles the keyboard. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `getAriaValueText` | `(formattedValue: string, value: number) => string` |  | Returns the input's `aria-valuetext`, e.g. `"-6 dB"`. Defaults to the formatted value. |
| `inputRef` | `React.Ref<HTMLInputElement>` |  |  |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Value

The formatted value (`format`/`locale` on Root). With `editable`, a double click, or Enter while it has focus, lets you type a value: Enter commits it (a unit or a decimal comma is fine, and it is clamped), Escape drops it, and it reports `reason: "input-change"`. Renders a `<output>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `children` | `\| React.ReactNode \| ((formattedValue: string, value: number) => React.ReactNode)` |  |  |
| `editable` | `boolean` | `false` | Allows typing a value: double-click, or press Enter while it has focus. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Mark

A graduation on the dial at `value`, placed on the knob's scale: a layer the size of the knob, rotated to where `value` sits, so a tick drawn at its top lands on the sweep. Its `--knob-mark-angle` CSS variable is that rotation, to turn a label back upright. It carries `data-active` while the value has reached it from the anchor. Decorative: the input announces the value. Renders a `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the mark sits, on the knob's scale. |

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |
| `data-active` | Whether the value has reached this mark from the anchor. |

### Label

Accessible label, associated with the thumb's input. Renders a `<div>` element.

**Data attributes**

| Attribute | Description |
| --- | --- |
| `data-disabled` | Present when disabled. |
| `data-dragging` | Whether a pointer is dragging the knob. |

### Registry component

`Knob` accepts `size` and `className` plus primitive props from `KnobPrimitive.Root.Props`. `aria-label`, `aria-labelledby`, and `aria-describedby` are forwarded to the knob's input.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number` |  | Where the mark sits, on the knob's scale. |
| `defaultValue` | `number` | `middle of the travel` | Uncontrolled starting value, and the reset target of a double tap, an Alt + click and the Delete key. Defaults to the middle of the dial's travel. |
| `min` | `number` | `0` | Minimum. |
| `max` | `number` | `100` | Maximum. |
| `step` | `number` | `1` | Quantization step (drag, wheel, keyboard, reset). |
| `largeStep` | `number` | `10` | Step for Page Up/Page Down and Shift + Arrow keys. |
| `anchor` | `number` | `min` | Value the indicator arc grows from (e.g. `0` on a -1…1 pan knob). Defaults to `min`. |
| `dragMode` | `"circular" \| "vertical" \| "revolution"` | `"circular"` | How pointer travel turns into value. |
| `scale` | `"linear" \| "log" \| { toPosition, toValue }` | `"linear"` | How the value maps onto the dial's travel. |
| `marks` | `(number \| { value, label? })[]` |  | Graduations drawn around the dial, on its scale. Those the value has reached from `anchor` light up. |
| `valueLabel` | `"auto" \| "always" \| "hidden"` | `"auto"` | When the value shows under the dial: `auto` while hovered, turned or focused from the keyboard. |
| `formatValue` | `(value: number) => string` | `format` | How the value prints in that readout and in what screen readers announce, e.g. `"8.7 kHz"`. |
| `editableValue` | `boolean` | `false` | Lets you type the value in the readout: a double click, or Enter while it has focus. |
| `disabled` | `boolean` | `false` | Disables pointer, wheel, and keyboard. |
| `format` | `Intl.NumberFormatOptions` |  | Formats the value for `Knob.Value` and the input's `aria-valuetext`. |
| `locale` | `Intl.LocalesArgument` | `runtime` | Locale used by `format`. |
| `name` | `string` |  | Identifies the field when a form is submitted. |
| `onValueChange` | `(value: number, eventDetails) => void` |  | Fires on every change: drag, keyboard, wheel, reset and input changes. |
| `onValueCommitted` | `(value: number, eventDetails) => void` |  | Fires when an interaction ends with a new value. |
| `size` | `"sm" \| "default" \| "lg" \| "xl"` | `"default"` | Diameter variant. |
| `className` | `string` |  | On the root wrapper. |

`@audio-ui/react/knob` exposes `Knob.Root`, `Knob.Control` (pointer, wheel, reset), `Knob.Track` and `Knob.Indicator` (SVG arcs; `strokeWidth` in the 48×48 viewBox), `Knob.Thumb` (rotates with the value and holds the input), `Knob.Value` (formatted `<output>`), `Knob.Mark` (a graduation at a `value`, rotated onto the dial's scale, with `data-active` once reached), and `Knob.Label`. Every part accepts a `render` prop, `className` / `style` as functions of state, and exposes `data-disabled` / `data-dragging`.

## Examples

### Rail, anchor, and range highlight

Use `anchor` to show a reference point on the rail (for example the center of a bipolar control). The arc highlights the interval between `anchor` and the current value.

```tsx
"use client";

import { Knob } from "@/components/audio-ui/knob";

export function KnobArcAndAnchorDemo() {
  return (
    <div className="flex flex-wrap items-center gap-8">
      <div className="flex flex-col items-center gap-2">
        <Knob aria-label="Level" defaultValue={72} max={100} min={0} step={1} />
        <p className="text-center text-muted-foreground text-xs">Value only</p>
      </div>
      <div className="flex flex-col items-center gap-2">
        <Knob
          anchor={30}
          aria-label="Level from 30"
          defaultValue={75}
          max={100}
          min={0}
          step={1}
        />
        <p className="text-center text-muted-foreground text-xs">
          With <code className="text-foreground">anchor</code> range
        </p>
      </div>
    </div>
  );
}
```

### Logarithmic scale

`scale="log"` spreads a frequency evenly by ratio, so a quarter turn goes from 1 kHz to 8.7 kHz instead of packing the useful range into the first few degrees.

```tsx
"use client";
import { useState } from "react";
import { Knob } from "@/components/audio-ui/knob";

const formatHertz = (hertz: number) =>
  hertz >= 1000 ? `${(hertz / 1000).toFixed(1)} kHz` : `${hertz} Hz`;

export function KnobFilterControlDemo() {
  const [cutoff, setCutoff] = useState(1000);

  return (
    <div className="flex flex-col items-center gap-2">
      <Knob
        aria-label="Cutoff"
        max={20_000}
        min={20}
        onValueChange={setCutoff}
        scale="log"
        step={1}
        value={cutoff}
        valueLabel="hidden"
      />
      <output className="text-sm tabular-nums">{formatHertz(cutoff)}</output>
    </div>
  );
}
```
