Primitives
How the headless primitives of @audio-ui/react are put together, styled, controlled and read, the same way for every one of them.
All primitives in @audio-ui/react share one structure. The Knob shows it, and the others follow the same pattern.
Parts
A primitive is a set of parts you assemble. Each part renders one element and does one job: the root owns the value, the control takes the pointer, the thumb takes the focus.
import { Knob } from "@audio-ui/react";
<Knob.Root defaultValue={50}>
<Knob.Label>Cutoff</Knob.Label>
<Knob.Control>
<Knob.Track />
<Knob.Indicator />
<Knob.Thumb aria-label="Cutoff" />
</Knob.Control>
<Knob.Value />
</Knob.Root>Nothing is painted: no colour, size or theme comes from a primitive. Each part sets only the inline styles its behaviour needs (touch-action on a drag surface, position: relative on a container that holds absolutely placed parts, pointer-events on decorations), and the look is yours. Parts you leave out are not rendered, so a knob with no value readout is a knob without Knob.Value.
Style from state
State is on the element, never behind a prop you have to read. Style it with the attribute selectors of your CSS or the variants of your framework:
<Knob.Thumb className="cursor-grab data-dragging:cursor-grabbing" />- Attributes and variables. A part has
data-disabled,data-draggingand the like when they are true, and no attribute when they are not. What changes too often to be an attribute is a CSS variable, such as a knob mark's angle or a timeline's unit. Headless styling lists both for every part. - State functions.
classNameandstylecan be functions of the part's state:
<Knob.Thumb className={(state) => (state.dragging ? "ring-2" : "ring-0")} />Render another element
Every part takes a render prop to change the element it renders or to compose it with your own component. Its props, ref and handlers are merged with yours, not replaced:
<Knob.Control render={<MyDial />} />
<Timeline.Clip render={(props, state) => <article {...props} />} />A handler you pass runs alongside the part's own. A consumer's onPointerDown does not switch the drag off.
Control the value
A primitive is uncontrolled with defaultValue, or controlled with value. Either way it tells you what changed and why:
<Knob.Root
onValueChange={(value, eventDetails) => {
if (eventDetails.reason === "wheel") {
eventDetails.cancel(); // refuse this one
}
}}
onValueCommitted={(value, eventDetails) => save(value)}
value={cutoff}
/>onValueChangefires on every change, witheventDetails.reason("drag","wheel","keyboard","reset","input-change"...) andeventDetails.event, the event behind it.eventDetails.cancel()refuses the change: a controlled value stays put, an uncontrolled one does not move.onValueCommittedfires once, when a gesture that changed the value ends. That is the time to start a note, push to an undo stack or save, not on every move of a drag.- While a pointer drags, the part follows it even if your controlled value is late, and takes the value back when the pointer is released.
Live values
A playhead, a meter or a waveform that follows a signal changes sixty times a second, too often to go through React state. Give it a source instead, an object with getSnapshot and subscribe, and the part writes each value straight to the DOM:
import { createSource } from "@audio-ui/react";
const position = createSource(0);
// in your audio loop
position.set(audioContext.currentTime);
<Timeline.Root source={position}>
<Timeline.Lane>
<Timeline.Playhead />
</Timeline.Lane>
</Timeline.Root>Your page does not re-render while it plays.
Accessibility
Each control holds a native <input type="range">, visually hidden. It takes the focus, answers the arrow, Page, Home and End keys, announces its value and carries the accessible name, so a screen reader and the keyboard treat it as the slider it is. Give the thumb an aria-label, or a Label part, and format what is read out with getAriaValueText or format. Pointer presses do not draw the keyboard focus ring; keyboard focus does.
Import only what you use
Import from the root, or from a subpath to keep the rest out of your bundle:
import { Knob } from "@audio-ui/react/knob";A single primitive weighs a few kilobytes gzipped (a knob about 5 KB, a fader about 3 KB).
Forms
The parameter controls are form fields. Give one a name and its native input submits with the form, so a preset can be a plain <form>:
<form action={savePreset}>
<Knob.Root defaultValue={1000} name="cutoff">
...
</Knob.Root>
<Fader.Root defaultValue={40} max={100} name="gain">
...
</Fader.Root>
<XYPad.Root name="pad">...</XYPad.Root>
</form>- Knob and Fader submit one number under their
name. The Knob also takesformto belong to a form elsewhere in the page. - XY Pad submits two, as
pad.xandpad.y. - The other primitives (Envelope, Plot, Sequencer, Pad, Piano, PianoRoll, Timeline, Waveform, Meter) hold a value that is not a single number, a curve or a pattern for instance, so they do not submit by themselves: read it from
onValueChangeoronValueCommittedand send it as you like. - A disabled control is left out of the submission, as with any input.
The package
@audio-ui/react holds every primitive, each also its own entry point, @audio-ui/react/<primitive>, which ships that primitive alone. Each has its page in this section.
pnpm add @audio-ui/react
import { createSource, adjacentPoint, Envelope, Fader, Knob, /* … */ } from "@audio-ui/react";| Used by | arrangement, arrangement-engine, use-arrangement, use-audio-levels, use-loudness, use-piano-roll-playback, use-pitch, use-spectrum, use-time-selection, use-timeline-cursor |
|---|
| Export | Kind | What it is |
|---|---|---|
createSource | function | Creates a source you push to yourself, from an audio callback, a WebSocket, a worker or a test: call set with each new reading, and whatever reads the source follows. |
Source | interface | A value that changes outside React, in the shape useSyncExternalStore expects: subscribe registers a listener called on each change, and getSnapshot returns the current value. |
WritableSource | interface | A source that also takes values: what createSource returns. |
adjacentPoint | function | The nearest of points strictly after from (direction 1) or strictly before it (direction -1), or null when there is none that way: what jumping to the next or the previous locator, or cue, needs. |
Envelope | namespace | The Envelope primitive's parts, Envelope.Root and the rest. |
Fader | namespace | The Fader primitive's parts, Fader.Root and the rest. |
Knob | namespace | The Knob primitive's parts, Knob.Root and the rest. |
Meter | namespace | The Meter primitive's parts, Meter.Root and the rest. |
Pad | namespace | The Pad primitive's parts, Pad.Root and the rest. |
Piano | namespace | The Piano primitive's parts, Piano.Root and the rest. |
PianoRoll | namespace | The PianoRoll primitive's parts, PianoRoll.Root and the rest. |
Plot | namespace | The Plot primitive's parts, Plot.Root and the rest. |
Sequencer | namespace | The Sequencer primitive's parts, Sequencer.Root and the rest. |
ClipLimits | interface | What a clip edit keeps a clip within: the timeline's length, the shortest it may be, and its source's length. |
ClipValue | interface | A clip: a stretch of a source (a recording, a pattern) placed on the timeline. |
clipEnd | function | Where the clip ends on the timeline. |
copyRange | function | What the stretch from from to to holds of items: the part of each clip inside it, moved so the stretch starts at 0. |
coverClips | function | What a clip laid on a lane leaves of the clips already there: they are covered, not stacked. |
duplicateClip | function | A copy of the clip placed right after it, or as late as the timeline allows when there is not room for it. |
FADE_SHAPES | const | The fade shapes in the order a control steps through them. |
FadeShape | type | How a fade rises or falls: evenly, quick then slow, slow then quick, or eased at both ends. |
fadeGain | function | The gain a fade of shape has progress of the way through it, from 0 (silent) to 1 (full): the curve of a fade-in; a fade-out plays it backwards. |
insertTime | function | Opens a stretch length long at at: everything from there moves later, and a clip across at is cut in two around the gap. |
moveClip | function | Moves the clip to begin at start, keeping its length and its place in the source. |
nextFadeShape | function | The shape after shape, going round, for a control that steps through them. |
removeTime | function | Takes the stretch from from to to out of the timeline: what is in it goes, and everything after it moves back to close the gap. |
setClipFadeIn | function | Sets the fade-in's length, which cannot run into the fade-out. |
setClipFadeOut | function | Sets the fade-out's length, which cannot run into the fade-in. |
setClipFadeShape | function | Gives the fade at edge the shape shape. |
splitClip | function | Cuts the clip in two at at, a position on the timeline. |
Timeline | namespace | The Timeline primitive's parts, Timeline.Root and the rest. |
trimClipEnd | function | Trims the clip's end to end, as far as the timeline and the source allow. |
trimClipStart | function | Trims the clip's start to start: what is cut off stays in the source, so the clip's content does not move on the timeline. |
Waveform | namespace | The Waveform primitive's parts, Waveform.Root and the rest. |
XYPad | namespace | The XYPad primitive's parts, XYPad.Root and the rest. |