Principles
Rules every component in daw-ui follows. A change that breaks one of them needs a reason written down here first.
1. Headless, in the style of Base UI
The package ships behaviour, not looks. Someone else must be able to publish a shadcn registry with their own design on top of it, using only the public API.
- A component is a namespace of parts named like Base UI's:
Knob.Root,Knob.Control,Knob.Label,Knob.Value,Fader.Track,Fader.Thumb, … - A family is one thing and the parts that thing is made of. Ask of each part whether it is part of what the family is, or only uses it.
Fader.Tickis part of a fader, andTimeline.Playhead,Timeline.RulerandTimeline.Gridare part of showing time, so they belong to their families (asSlider.ThumbandAccordion.Itemdo in Base UI); parts of such a part take its name as a prefix, asSelect.ItemTextdoes. A component that only uses another one, by reading its context, has its own family, even when it usually sits inside it:Waveformreads a timeline's axis,Togglejoins aToggleGroup(as shadcn'sMessagesits in a conversation). A family that grows parts for several things is split along these lines, not by its number of parts. - Composition goes one way: a part may read the context of what contains it, a container never depends on what is placed in it. A timeline shows time, its axis, ruler, grid and a playhead over its whole height, and does not know whether it holds an arrangement, a piano roll, rows of tracks or nothing.
- Each part renders one element and takes that element's props, and
render(an element or a function) to replace the element.classNameandstyleare plain values, never functions of the part's state (section 7). - State is exposed as
data-*attributes (data-dragging,data-pressed,data-disabled,data-orientation, …) and, for values, as CSS variables on the root (--knob-value,--fader-value), so styling needs no JavaScript. - Thresholds on a value, such as "red above 0 dB", are declared once as a prop and exposed as an attribute that changes only when the value crosses one. The application never has to compare values in code to style a part.
- Types are exported per part:
Knob.Root.Props,Knob.Root.State. A registry wraps them the way shadcn wraps Base UI. - The library sets no colours, sizes, fonts or spacing. Inline styles are limited to what behaviour needs: positioning along a track (with logical properties) and
touch-action. data-slotand anything else registry-specific belongs in the registry, not here.
2. Accessibility
- Every control has the right role and state:
sliderfor knobs and faders,spinbuttonfor number boxes,meterfor level meters,buttonwitharia-pressedfor toggles. - Values are announced as users read them:
aria-valuetextcomes from the sameformatthat draws the value ("-6.0 dB", not "-6"). - Every part without visible text needs an accessible name. The library never invents one; a
Labelpart or anaria-labelfrom the consumer provides it. - Focus is visible and predictable. Groups use a roving tab index, so a 16×64 step grid is one tab stop. A press with a pointer moves focus to what it pressed without a focus ring, which is for the keyboard.
3. Internationalization
The library does not decide the language of an application. No English text is hard-coded: nothing a user sees or a screen reader announces comes from the library as English words.
- Numbers are formatted with
Intl.NumberFormatin the consumer's locale (the runtime default unlesslocaleis given): "1,50 kHz" in Polish, "1.50 kHz" in English. - Unit symbols that are the same in every language (Hz, kHz, dB, ms, s, %) and mathematical signs (−∞) may appear. Words and their abbreviations may not: the pan format takes its "L/R/C" (or "L/P/Ś") from the consumer.
- Parsing accepts both decimal separators and the Unicode minus sign.
4. Right-to-left layouts
Arabic, Hebrew and Persian interfaces run right to left, and a component must work there without extra code. The direction is read from the DOM (dir / CSS direction), not from a prop.
- What follows the reading direction: horizontal faders and meters fill from the inline start (the right edge in RTL); horizontal drag increases towards the inline end; Left and Right arrow keys on horizontal controls and in toggle groups move the value or focus along the reading direction; Home goes to the inline start.
- What does not: vertical controls (up is always more), knobs (clockwise is always more, as on hardware), and Up and Down keys. Time on a timeline also runs left to right, the convention of music software and notation, and so do the fields of a song position or a timecode. A piano keyboard is an instrument, not text: its low notes are on the left in every language.
- Formatted values render with
dir="auto": the text decides its own direction, so "-6.0 dB" keeps its order inside a right-to-left page, and a value with an Arabic unit reads right to left. - Positioning uses logical properties (
inset-inline-start, notleft). Where CSS has no logical form (translate,clip-path), the part reads the direction once when it mounts. Timelines and keyboards, which run left to right in every language, use physical properties on purpose. - RTL behaviour is covered by tests in real browsers, like any other layout-dependent behaviour.
5. Input
- Every pointer interaction has a keyboard equivalent. Painting toggles, or the values of a bar graph, by dragging has Shift+Arrow.
- Modifiers mean the same everywhere: Shift is fine adjustment, double-click and Delete reset, Cmd/Ctrl is the alternative action (e.g. additive solo).
- Mouse, pen and touch go through Pointer Events with pointer capture, so a drag continues outside the element and never leaves listeners behind.
- Toggles react on press, not on release, as in hardware and desktop DAWs.
- Text the components show is not selectable. Labels, value readouts, ticks, ruler labels, the fields of a number box and the text of buttons set
user-select: none: in a DAW the pointer drags all the time, and interface text is not document content, so a drag across a mixer or a double-click on a label must never select text. The one exception is a text input while the user edits a value in it. An application that wants a value copyable overrides it withstyle.
6. Gestures
Every user action is wrapped in onGestureStart / onGestureEnd: a drag, a key press, a burst of wheel events, a reset, a typed value, a paint stroke, an exclusive solo that turns five toggles off. An application uses them to make each action one undo step and to record automation. A gesture starts only when something changes.
An action that can apply to many items is run once for all of them, not by each item. In a DAW, what the user does to one selected thing they do to all of them: dragging one of five selected clips moves the five, trimming one trims all, painting one step paints a stroke. So one owner runs the gesture, applies it to every item in the selection, and reports it once, as a list of changes with one onGestureStart / onGestureEnd, which is one undo step however many items it moved. An item only starts the gesture where the pointer or focus is; acting on a single item is a selection of one, not a second code path. The owner also keeps the changes consistent across the group: limits apply to the group as a whole (the most constrained item stops everyone), and snapping follows the item the user holds, the others keeping their relative places.
Who that owner is depends on what the items are:
- A group control runs its own gestures. A
ToggleGroupis one control, like a radio group: its paint strokes and exclusive presses are what it is, the same in every application. - Editing things placed on a surface is an engine the application owns, through a hook; components are the interface. What a move does to clips on a timeline or notes in a piano roll, how they snap, which moves are allowed, differs between applications, and a block needs to act on it (select all, delete the selection, undo). So components only show: a timeline shows time, a region shows a clip. The block creates the editing engine with a hook, hands it to the parts through context, and controls it through its API, as shadcn's
Carouseltakes its behaviour from Embla. Without an engine the parts only display. The engine is a stable object, not state: during a gesture it writes to the DOM itself, and the block renders only when the gesture ends (section 7). It brings the ARIA roles, the selection state and the keys, so a block cannot forget them.
7. Performance
Performance is a requirement, measured on every change.
- Assume that everything renders often. A drag commits on every pointer event, and automation changes controlled values on every frame, on every channel. Work done per render is multiplied by the number of channels and the frame rate, so it is never "negligible". Parts therefore run no application code per render to style themselves:
classNameandstyletake values, not functions of state, and state reaches CSS only through attributes and CSS variables, which the browser applies without JavaScript. An API that invites per-render work in the hot path is a performance bug, even when each call is cheap. - Values that change at audio-visual rates never go through React state. Meters, and the value of knobs, faders and number boxes, are written straight to the DOM: a drag, a key or automation changes what the parts show without rendering anything. Values that change on their own (meter levels, automation, modulation) are read once per animation frame by one shared loop (
read), not pushed through props. - An interaction re-renders only what it changes: dragging one knob does not render its siblings.
- Drawing happens when what is drawn changes, not when it moves. Waveforms draw canvas tiles once and let CSS move and stretch them: playback and scrolling draw nothing that is drawn already. What must be drawn is queued and spread over frames within a budget, visible first, so that no frame is dropped for it.
- Layout is read at the start of a gesture, not on every pointer move.
- Budgets that are deterministic gate CI: React render counts per interaction (zero for a drag, zero for automation through
read, zero for running meters), bundle size. - Timings are measured and reported: frame times and input latency in Chromium under 4× CPU throttling on a stress page (64 channel strips, a 16×64 step grid). CI runners are too noisy to gate on them; the numbers go to the job summary.
- Core functions have micro-benchmarks (
vitest bench).
8. Values and state
- Every stateful part works controlled (
value+onValueChange) and uncontrolled (defaultValue). - Values are in natural units (Hz, dB, ms, −1…1 for pan). The travel position in [0, 1] is derived through a
Rangewith aScale. - Callbacks receive
detailswith thereasonand the nativeevent; value callbacks also receive thedelta, for relative uses such as endless encoders.
9. Packaging
- No runtime dependencies. The core (
@addstack/daw-ui) does not import React; the React binding is@addstack/daw-ui/react. - Safe to import during server rendering:
"use client"on React modules, nowindowaccess at import time. - ESM only,
sideEffects: false.
10. Specification and tests
specification.md describes the behaviour; tests in test/ (jsdom) and e2e/ (Playwright, Chromium, Firefox and WebKit) are its executable form. Pointer behaviour that depends on layout is tested in real browsers.