Styling
Style the parts with classes, data attributes and CSS variables, replace their elements, and build a registry on top.
The components ship behaviour, not looks. The only inline styles they set are what the behaviour needs: positions along a track (with logical properties, so they follow right-to-left layouts), the clipping of a meter's bar, touch-action: none on the elements you drag, and user-select: none on every part that shows text, so that dragging across controls never selects it (override it with style to make a value copyable). Everything else is yours.
Styling runs no code per render
In a DAW everything renders often: a drag commits on every pointer event, and automation changes controlled values on every frame, on every channel. So className and style take plain values, never functions of state, and a part's state reaches CSS only through data attributes and CSS variables. The browser applies them by itself, without running your code on each render (principles, section 7).
<Knob.Control className="cursor-ns-resize data-dragging:cursor-grabbing" />style is merged over the part's own inline style.
Data attributes
Every part of a control carries its state as attributes, which work in plain CSS and as Tailwind variants:
<Toggle className="bg-neutral-800 data-pressed:bg-orange-500 data-disabled:opacity-50" />
<Knob.Range className="stroke-orange-500 data-dragging:stroke-orange-400" />| Attribute | Set on |
|---|---|
data-dragging | Knob, Fader and NumberBox parts, while the value is dragged. |
data-disabled | Every part of a disabled control. |
data-bipolar | Knob and Fader parts, when origin lies inside the range. |
data-zone | Knob, Fader and NumberBox parts, and the Meter root: the zone of zones the value is in. |
data-orientation | Fader, Meter and ToggleGroup roots. |
data-editing | NumberBox root and field, while the value is typed. |
data-pressed | Toggle, while it is on. |
data-painting | ToggleGroup, during a paint stroke: "on" or "off". |
data-active, data-clipped | Meter root while there is signal, and after a clip; data-clipped also on Meter.Clip. |
Zones
A threshold, such as "red above 0 dB", is declared once instead of compared in code. zones maps names to lower bounds in the control's units, and every part gets data-zone with the zone the value is in. The attribute changes only when the value crosses a bound:
<Fader.Root min={-Infinity} max={6} scale={scales.decibel} zones={{ hot: 0 }}>
<Fader.Control>
<Fader.Track>
<Fader.Range className="bg-orange-500 data-[zone=hot]:bg-red-500" />
<Fader.Tick value={6} className="data-[zone=hot]:text-red-500">+6</Fader.Tick>
<Fader.Thumb />
</Fader.Track>
</Fader.Control>
</Fader.Root>A value at a bound belongs to the zone below it: 0 dB is not yet hot. A tick carries the zone of its own value, so the marks of the hot zone can be red too. A meter takes zones for its level and writes data-zone from its frame loop, without React.
For a colour that changes smoothly with the value, use the CSS variables below, e.g. color-mix(in oklch, orange, red calc(var(--fader-value) * 100%)).
CSS variables
Roots expose their value as CSS variables, for styling that follows the value without JavaScript:
| Variable | |
|---|---|
--knob-value, --fader-value | Travel of the value, from 0 to 1. |
--knob-angle | Rotation of the value, e.g. rotate: var(--knob-angle) on your own knob image. |
--meter-level, --meter-peak | Travel of the level and the held peak, from 0 to 1. |
The render prop
render replaces the element a part renders, keeping its behaviour. Pass an element to merge the part's props into, or a function that receives them and the state:
<Knob.Control render={<button type="button" />} />
<Toggle render={(props, state) => <MyButton {...props} glowing={state.pressed} />} />Event handlers are merged: yours runs first, and calling event.preventDefault() in it skips the part's own handling.
Building a registry
Base UI sits under shadcn/ui; daw-ui can sit under yours the same way. Wrap the parts in the components of your design system, with data-slot for targeting, and publish them as a shadcn registry. Each part exports its types, so wrappers stay typed:
// components/ui/knob.tsx
import { Knob as KnobPrimitive } from '@addstack/daw-ui/react';
export function Knob({ label, className, ...props }: KnobPrimitive.Root.Props & { label: string }) {
return (
<KnobPrimitive.Root data-slot="knob" className={cn('group grid justify-items-center gap-1', className)} {...props}>
<KnobPrimitive.Label className="text-xs text-muted-foreground">{label}</KnobPrimitive.Label>
<KnobPrimitive.Control data-slot="knob-control" className="size-12">
<svg viewBox="0 0 100 100">
<KnobPrimitive.Track className="fill-none stroke-muted stroke-9" />
<KnobPrimitive.Range className="fill-none stroke-primary stroke-9" />
<KnobPrimitive.Pointer className="stroke-foreground stroke-7" from={10} to={28} />
</svg>
</KnobPrimitive.Control>
<KnobPrimitive.Value className="font-mono text-xs" />
</KnobPrimitive.Root>
);
}The playground's source is written like this: its components/ui sections are what a registry would ship.