Headless styling

How to style the primitives of @audio-ui/react with nothing but CSS: each part exposes its state as data attributes and its live values as CSS variables, and the reference below lists them for every primitive.

The primitives paint nothing: no colour, no size, no theme. What a part looks like comes from your CSS, and the part gives that CSS everything it needs: its state as attributes, and what changes too often to be an attribute as a variable. A skin needs no React state of its own.

Style from the state

A part carries its state on the element. A knob that is being dragged has data-dragging, a disabled fader data-disabled, a fader a data-orientation. Select on them:

.my-knob [data-dragging] {
  box-shadow: 0 0 0 4px color-mix(in oklab, currentColor 20%, transparent);
}

.my-knob [data-disabled] {
  opacity: 0.4;
}

A boolean that is off has no attribute, so the absence is a state too: :not([data-active]) styles the rest.

Style from the variables

What changes on every frame is a CSS variable, set on the element, not an attribute, so the browser does not re-match selectors on each change. A knob's mark turns with --knob-mark-angle, a pad's crosshair moves with --xy-pad-x and --xy-pad-y:

.my-knob-mark {
  transform: rotate(var(--knob-mark-angle));
}

What stays inline

Three styles are behaviour, not look, and each part sets them itself:

  • touch-action: none on a surface that is dragged, so a touch drags the control and does not scroll the page.
  • position: relative on a container that holds parts placed absolutely, such as a fader's mark or a knob's arc, so those parts sit in the container and not on the page.
  • pointer-events: none on decoration that must not take the pointer.

A stylesheet can still change anything else about them. The behaviour of these three is the primitive's contract.

An inline style beats any class, so a class cannot change them. To place a part itself, say an envelope at the corner of a panel, pass the style prop: it is applied after the part's own, so style={{ position: "absolute", top: 8 }} wins. In a skin of CSS alone, put the part in an element you position.

What a part sets inline for its value, such as a thumb's left and translate or an indicator's height, is the value itself: a class does not replace it, it adds to it.

Give each part a size

A part has no size of its own, and one that sits at a position along a line or a plane does not lay itself out: it needs a box, and for a part placed with bottom, left or inset, position: absolute. These are the least that draws each of the five controls below, from the part names of their anatomy:

/* Fader: the control is the box, the thumb sits at the value */
.fader-control { width: 2rem; height: 8rem; }
.fader-track { width: 0.5rem; height: 100%; margin-inline: auto; background: #333; }
.fader-indicator { position: absolute; background: #6cf; }
.fader-thumb { width: 1.5rem; height: 0.75rem; background: #eee; }

/* XYPad: the control is the surface */
.xypad-control { width: 8rem; height: 8rem; background: #222; }
.xypad-thumb { position: absolute; width: 1rem; height: 1rem; border-radius: 50%; background: #6cf; }

/* Knob: the arcs are SVGs that fill the control, one over the other */
.knob-control { width: 4rem; height: 4rem; }
.knob-track,
.knob-indicator { position: absolute; inset: 0; width: 100%; height: 100%; }
.knob-track { color: #333; }
.knob-indicator { color: #6cf; }
.knob-thumb { position: absolute; inset: 0; } /* turns with the value */
.knob-thumb::after { content: ""; position: absolute; top: 12%; left: 50%; width: 2px; height: 28%; background: #eee; transform: translateX(-50%); }

/* Meter: the channel is the bar, the indicator is scaled to the level */
.meter { height: 8rem; display: flex; gap: 2px; }
.meter-channel { width: 0.75rem; height: 100%; background: #222; }
.meter-indicator { position: absolute; bottom: 0; background: #6c6; }

/* Plot: the root is the box, the graph fills it */
.plot { width: 12rem; height: 6rem; background: #222; }
.plot-line { stroke: #6cf; }

Each class goes on the part it is named after, with className. Sizes are in your units and colours are yours; what matters is which part takes which rule.

Reference

Generated from the source of each primitive: the data attributes of the state each part exposes, and the CSS variables it sets. A value of the state of a plain type (a boolean, a number, a string) is an attribute, unless the primitive keeps it off the element. A callback or an object is not.

Envelope

State on every part that takes it: data-disabled, data-dragging.

PartData attributesCSS variables

Fader

State on every part that takes it: data-disabled, data-dragging, data-orientation.

PartData attributesCSS variables

Knob

State on every part that takes it: data-disabled, data-dragging.

PartData attributesCSS variables
Mark--knob-mark-angle

Meter

PartData attributesCSS variables
Rootdata-clipped

Pad

State on every part that takes it: data-columns, data-disabled.

PartData attributesCSS variables
Item--pad-velocity

Piano

State on every part that takes it: data-disabled, data-max-pitch, data-min-pitch, data-orientation.

PartData attributesCSS variables

Piano-roll

State on every part that takes it: data-disabled, data-draw-style, data-folded, data-max-pitch, data-min-length, data-min-pitch, data-mode.

PartData attributesCSS variables
Rootdata-has-selection
Rowsdata-in-scale

Plot

State on every part that takes it: data-disabled, data-max-x, data-max-y, data-min-x, data-min-y.

PartData attributesCSS variables
Cursordata-active--plot-cursor-x, --plot-cursor-y

Sequencer

State on every part that takes it: data-disabled.

PartData attributesCSS variables
Rootdata-current, data-painting
Velocity--sequencer-velocity

Timeline

State on every part that takes it: data-default-position, data-default-zoom, data-disabled, data-duration, data-large-step, data-max-zoom, data-min-zoom, data-position, data-snap-offset, data-snap-step, data-step, data-value, data-zoom.

PartData attributesCSS variables
ClipFade--clip-fade-in, --clip-fade-out
Clip--clip-drag-y, --clip-fade-in, --clip-fade-in-max, --clip-fade-out, --clip-fade-out-max, --clip-offset, --clip-source-length
Lanedata-timeline-lane

Waveform

PartData attributesCSS variables
Barsdata-index, data-played--waveform-level
Canvasdata-theme--waveform-bar-radius, --waveform-color, --waveform-progress-color
Rootdata-active, data-outside

Xypad

State on every part that takes it: data-disabled, data-dragging.

PartData attributesCSS variables
Root--xy-pad-x, --xy-pad-y
Built by Ouest Labs, the source code is available on GitHub.
RSS