Skip to content

Knob

A rotary control for the parameters of an audio plugin, turned by a vertical drag, the wheel or the keys, with its value in a readout below the dial.

Gain0.0 dB
import { formatDecibels, Knob } from '@contour-ui/react';

<Knob.Root min={-60} max={12} step={0.1} defaultValue={0} formatValue={formatDecibels}>
  <Knob.Label>Gain</Knob.Label>
  <Knob.Control />
  <Knob.ValueText />
</Knob.Root>;

Knob is the rotary control of plugin interfaces: gain, cutoff, resonance, mix. It is the Headless Knob with the look of contour-ui: a dial whose travel is an arc of 270°, open at the bottom. The range, in the accent color, runs along the track from the origin to the value, and the thumb points at the value. The label sits above the dial and the readout below it, in the monospace value text style, so digits do not shift while the knob turns.

Drag the dial up to raise the value and down to lower it, turn the wheel over it, or use the keys of a slider; a double click resets it. Knob.Control draws the track, the range and the thumb when it has no children.

The value

min and max set the range, 0 to 100 by default. Without step, the value is continuous; with it, the value stops at multiples of step from min. defaultValue is the first value of a knob that keeps its own, and the value a double click resets to, a parameter's default; pass value and onValueChange to keep the value yourself. onValueChange receives { value }.

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

<Knob.Root value={cutoff} onValueChange={(details) => setCutoff(details.value)}>
  …
</Knob.Root>;

formatValue writes the readout and what screen readers say. The formatters of @contour-ui/react write values as plugins show them, without Intl, with a real minus sign: formatDecibels (−6.0 dB), formatFrequency (440 Hz, 1.25 kHz), formatDuration (12.5 ms, 1.20 s), formatPercent (50%) and formatNumber. Without formatValue, the readout is the number, with the decimals of step.

Gestures

Each change the user makes is a gesture with a start and an end: a drag from the press to the release, a run of wheel events until the wheel rests for 300 ms, a key, a reset. onValueChangeStart receives the value before it and onValueChangeEnd the value it left, so a plugin can record the changes in between as one step of the host's automation and undo, with the beginGesture() and endGesture() of a parameter. setValue() changes the value from code without either.

<Knob.Root
  value={param.value}
  onValueChangeStart={() => param.beginGesture()}
  onValueChange={(details) => param.set(details.value)}
  onValueChangeEnd={() => param.endGesture()}
>
  …
</Knob.Root>

Scales

scale spreads the range over the travel. 'linear' is the default. 'log' gives equal ratios equal travel, as the ear hears pitch: 20 Hz to 200 Hz takes as much of it as 2 kHz to 20 kHz. Use it for frequencies and times, with a range above zero. powerScale(exponent) is a curve in between, for a range that starts at zero, and any Scale of your own works too.

Cutoff1.00 kHz
import { formatFrequency, Knob } from '@contour-ui/react';

<Knob.Root min={20} max={20000} scale="log" defaultValue={1000} formatValue={formatFrequency}>
  <Knob.Label>Cutoff</Knob.Label>
  <Knob.Control />
  <Knob.ValueText />
</Knob.Root>;

Bipolar knobs

origin is where the range starts: min by default. A pan, or a gain from −12 dB to +12 dB, sets it in the middle: the range then runs from the middle either way, and has no length at the origin. Without defaultValue, a knob starts at its origin and a double click goes back to it.

PanC
import { formatNumber, Knob } from '@contour-ui/react';

function formatPan(value: number): string {
  return value === 0 ? 'C' : `${value < 0 ? 'L' : 'R'} ${formatNumber(Math.abs(value))}`;
}

<Knob.Root min={-100} max={100} step={1} origin={0} formatValue={formatPan}>
  <Knob.Label>Pan</Knob.Label>
  <Knob.Control />
  <Knob.ValueText />
</Knob.Root>;

Sizes

size is sm, md (the default) or lg: the diameter of the dial, the --ct-size-knob-* tokens. The strokes of the dial grow with it.

sm65
md65
lg65

States and forms

  • disabled greys the knob out: it takes no focus and ignores the pointer, the wheel and the keys, and the wheel scrolls the page over it.
  • readOnly shows the value, and the knob still takes focus, but the user cannot change it. setValue() still does.
  • Knob.HiddenInput submits the value with a form, under name; form is the id of a form the knob is outside of. A disabled knob's value is left out, as a disabled input's is.
Disabled40
Read-only70

Pointer and wheel

InputAction
Drag up, downRaises or lowers the value: 200 pixels from min to max (dragDistance). Only the vertical movement counts, and the drag goes on outside the dial
Shift dragTen times as far, for fine moves
WheelA notch of a mouse wheel moves a twentieth of the travel, a tenth of that with Shift. The page does not scroll
Double clickResets the value to defaultValue, or the origin

pointerLock locks the pointer to the dial while it drags: the pointer hides and stops at no edge of the screen, and shows again where the drag started. The press releases it, and so does Escape, which ends the drag.

Keyboard

KeyAction
TabMoves focus to the dial, and out of it
↑ →Raises the value by a hundredth of the travel, or one step
↓ ←Lowers it the same way
Shift + arrowA thousandth of the travel, or one step
Page Up, Page DownA tenth of the travel
Home, EndGoes to min or max

With a step larger than the share of the travel a key moves, the key moves one step.

Anatomy

PartElementClassAttributes
Knob.Rootdivcontour-knobdata-scope="knob" data-part="root", data-size, aria-disabled, data-disabled, data-readonly, data-dragging; --knob-position, --knob-angle
Knob.Labelspancontour-knob__labeldata-part="label", id
Knob.Controlsvgcontour-knob__controldata-part="control", role="slider", tabindex, aria-valuemin, aria-valuemax, aria-valuenow, aria-valuetext, aria-labelledby, aria-orientation="vertical", aria-disabled, aria-readonly, data-focus-visible, viewBox="0 0 100 100"
Knob.Trackpathcontour-knob__trackdata-part="track", d
Knob.Rangepathcontour-knob__rangedata-part="range", d, empty at the origin
Knob.Thumblinecontour-knob__thumbdata-part="thumb", x1, y1, x2, y2
Knob.ValueTextspancontour-knob__value-textdata-part="value-text"; the text of the value
Knob.HiddenInputinputcontour-knob__hidden-inputdata-part="hidden-input", type="hidden", name, form, value, disabled

Every part carries data-scope="knob", data-disabled, data-readonly and data-dragging, present and empty while the state holds. The track, the range and the thumb stroke in currentColor with round caps: set their colors with color.

Props

Knob.Root:

PropTypeDefault
valuenumber—
defaultValuenumber: the first value, and the value of a resetorigin
onValueChange(details: { value: number }) => void—
onValueChangeStart(details: { value: number }) => void: a gesture starts—
onValueChangeEnd(details: { value: number }) => void: it ends—
minnumber0
maxnumber100
stepnumber: none for a continuous value—
scale'linear', 'log' or a Scale'linear'
originnumber: where the range startsmin
formatValue(value: number) => stringthe number
dragDistancenumber: the pixels of a drag from min to max200
pointerLockbooleanfalse
disabledbooleanfalse
readOnlybooleanfalse
namestring: the name of the hidden input—
formstring: the id of its form—
size'sm', 'md', 'lg''md'

Every part takes asChild and the props of its element; className is merged with the part's class. Knob.ValueText shows its children in place of the text of the value.

Accessibility

  • The dial is a vertical slider, named by Knob.Label or by aria-label on Knob.Control. It gives screen readers the text of formatValue (aria-valuetext), so they say "Gain, −6.0 dB" rather than a bare number, and the keys of a slider move it.
  • Focus from the keyboard shows a ring around the dial (data-focus-visible); a press focuses it without one, as a click does a button, and the next key shows it.
  • A disabled knob leaves the tab order (aria-disabled, on the root too, so checkers such as axe count its label and readout as the text of an inactive control); a read-only one stays in it (aria-readonly).
  • The readout is text next to the dial: the slider carries the value for assistive technology.

Tokens

TokenUsed for
--ct-size-knob-sm, --ct-size-knob-md, --ct-size-knob-lgthe diameter of the dial
--ct-color-border-defaultthe track
--ct-color-accent-fill, --ct-color-accent-fill-hoverthe range, and the range while hovered or dragged
--ct-color-fg-defaultthe thumb and the readout
--ct-color-fg-mutedthe label
--ct-color-fg-disabled, --ct-color-border-subtlea disabled knob, and its track
--ct-text-label, --ct-text-valuethe label and the readout
--ct-space-control-gapbetween the label, the dial and the readout
--ct-radius-roundthe round dial, which its focus ring follows
--ct-color-border-focus, --ct-border-width-focusthe focus ring
--ct-motion-feedbackthe color change of the range