daw-ui

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" />
AttributeSet on
data-draggingKnob, Fader and NumberBox parts, while the value is dragged.
data-disabledEvery part of a disabled control.
data-bipolarKnob and Fader parts, when origin lies inside the range.
data-zoneKnob, Fader and NumberBox parts, and the Meter root: the zone of zones the value is in.
data-orientationFader, Meter and ToggleGroup roots.
data-editingNumberBox root and field, while the value is typed.
data-pressedToggle, while it is on.
data-paintingToggleGroup, during a paint stroke: "on" or "off".
data-active, data-clippedMeter 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-valueTravel of the value, from 0 to 1.
--knob-angleRotation of the value, e.g. rotate: var(--knob-angle) on your own knob image.
--meter-level, --meter-peakTravel 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.

On this page