Lab

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-dragging and 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. className and style can 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}
/>
  • onValueChange fires on every change, with eventDetails.reason ("drag", "wheel", "keyboard", "reset", "input-change"...) and eventDetails.event, the event behind it. eventDetails.cancel() refuses the change: a controlled value stays put, an uncontrolled one does not move.
  • onValueCommitted fires 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 takes form to belong to a form elsewhere in the page.
  • XY Pad submits two, as pad.x and pad.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 onValueChange or onValueCommitted and 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 byarrangement, arrangement-engine, use-arrangement, use-audio-levels, use-loudness, use-piano-roll-playback, use-pitch, use-spectrum, use-time-selection, use-timeline-cursor
ExportKindWhat it is
createSourcefunctionCreates 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.
SourceinterfaceA value that changes outside React, in the shape useSyncExternalStore expects: subscribe registers a listener called on each change, and getSnapshot returns the current value.
WritableSourceinterfaceA source that also takes values: what createSource returns.
adjacentPointfunctionThe 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.
EnvelopenamespaceThe Envelope primitive's parts, Envelope.Root and the rest.
FadernamespaceThe Fader primitive's parts, Fader.Root and the rest.
KnobnamespaceThe Knob primitive's parts, Knob.Root and the rest.
MeternamespaceThe Meter primitive's parts, Meter.Root and the rest.
PadnamespaceThe Pad primitive's parts, Pad.Root and the rest.
PianonamespaceThe Piano primitive's parts, Piano.Root and the rest.
PianoRollnamespaceThe PianoRoll primitive's parts, PianoRoll.Root and the rest.
PlotnamespaceThe Plot primitive's parts, Plot.Root and the rest.
SequencernamespaceThe Sequencer primitive's parts, Sequencer.Root and the rest.
ClipLimitsinterfaceWhat a clip edit keeps a clip within: the timeline's length, the shortest it may be, and its source's length.
ClipValueinterfaceA clip: a stretch of a source (a recording, a pattern) placed on the timeline.
clipEndfunctionWhere the clip ends on the timeline.
copyRangefunctionWhat the stretch from from to to holds of items: the part of each clip inside it, moved so the stretch starts at 0.
coverClipsfunctionWhat a clip laid on a lane leaves of the clips already there: they are covered, not stacked.
duplicateClipfunctionA copy of the clip placed right after it, or as late as the timeline allows when there is not room for it.
FADE_SHAPESconstThe fade shapes in the order a control steps through them.
FadeShapetypeHow a fade rises or falls: evenly, quick then slow, slow then quick, or eased at both ends.
fadeGainfunctionThe 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.
insertTimefunctionOpens a stretch length long at at: everything from there moves later, and a clip across at is cut in two around the gap.
moveClipfunctionMoves the clip to begin at start, keeping its length and its place in the source.
nextFadeShapefunctionThe shape after shape, going round, for a control that steps through them.
removeTimefunctionTakes 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.
setClipFadeInfunctionSets the fade-in's length, which cannot run into the fade-out.
setClipFadeOutfunctionSets the fade-out's length, which cannot run into the fade-in.
setClipFadeShapefunctionGives the fade at edge the shape shape.
splitClipfunctionCuts the clip in two at at, a position on the timeline.
TimelinenamespaceThe Timeline primitive's parts, Timeline.Root and the rest.
trimClipEndfunctionTrims the clip's end to end, as far as the timeline and the source allow.
trimClipStartfunctionTrims 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.
WaveformnamespaceThe Waveform primitive's parts, Waveform.Root and the rest.
XYPadnamespaceThe XYPad primitive's parts, XYPad.Root and the rest.
Built by Ouest Labs, the source code is available on GitHub.
RSS