For agents
Project
Arrangement
Piano roll tools
Transport
Mixing
Space and modulation
Utility and instruments
Audio UI
An arrangement is tracks of clips along a song. It comes in two layers that install together and apart. The timeline draws and moves the clips on a zoomable ruler: lanes, ranges, a playhead and a navigator, with no notion of tempo. The arrangement parts hold what a DAW's arrangement needs around it: the state, the clips, track reordering, locators and time signatures, automation and MIDI playback. Each is a file you own, takes its state as values and reports changes through callbacks, so the arrangement can live in your own store and play through an engine of your own.
Installation
The whole arrangement installs with npx shadcn@latest add @audio-ui/arrangement-editor: every part below, with the timeline. A part installs alone by its name (@audio-ui/arranged-clip, @audio-ui/use-track-reorder…). The preview above is @audio-ui/block-timeline, which wires them into a screen. The tabs below are the timeline on its own.
pnpm dlx shadcn@latest add @audio-ui/timeline
The timeline
Audio UI
The preview is the timeline alone: a loop, a ruler and two tracks with clips. A clip let go over another covers it, as coverClips does (see Clips below): drop Fill inside Groove and Groove is cut in two. The registry component wraps the Timeline primitive from @audio-ui/react. The timeline has no notion of tempo: its units are whatever you count in (seconds, beats, pulses or steps). Musical time comes from the musical-time lib.
Overview
- Units and zoom:
durationis the length in your units, andzoomis the number of pixels per unit. Withoutzoom, the timeline fits its width. With it, every lane is as long as the timeline and the viewport scrolls. - Zooming: Cmd/Ctrl + scroll, or a trackpad pinch, zooms around the pointer. Every zoom change keeps its anchor point in place, or the middle of the view when there is none.
- Grid: with a
snapStep, the ruler snaps to the grid. Hold Cmd/Ctrl to place the playhead off the grid, and Shift to scrub four times finer. - Playback: pass a live
sourceand every playhead follows it in the DOM, without a React render per frame. - Ranges: a
TimelineRangeis a stretch of the timeline, such as a loop, a selection or a clip. Drag it to move it, drag a handle to resize it, or, withdrawable, drag across an empty lane to draw a new one. Ranges snap to the grid. - Navigator:
TimelineNavigatorshows the whole timeline as one bar, with the visible part inside it. Drag the window to scroll, drag an edge to zoom, and double-click to show everything. Withscrollbar="hidden"on the viewport, it replaces the scrollbar. - Accessibility: the ruler, each range and range handle, and the navigator's window and edges hold a native
<input type="range">, so every pointer action has a keyboard equivalent. The ruler's input spans the timeline: it takes focus, carriesaria-labeland announces the position (or the text fromgetAriaValueText, such as "Bar 3, beat 2"). The arrow keys move bystep, Page Up / Page Down and Shift bylargeStep, and Home / End go to the ends.
Anatomy
Import the primitive and assemble its parts.
import { Timeline } from "@audio-ui/react";
<Timeline.Root>
<Timeline.Viewport>
<Timeline.Ruler>
<Timeline.Mark value={0} />
<Timeline.Playhead />
</Timeline.Ruler>
<Timeline.Track>
<Timeline.TrackHeader>
<Timeline.TrackResizeHandle />
</Timeline.TrackHeader>
<Timeline.Lane />
</Timeline.Track>
<Timeline.Lane>
<Timeline.Range>
<Timeline.RangeHandle edge="start" />
<Timeline.RangeHandle edge="end" />
</Timeline.Range>
<Timeline.Clip>
<Timeline.ClipHeader />
<Timeline.ClipFade edge="in" />
<Timeline.ClipFade edge="out" />
<Timeline.ClipHandle edge="start" />
<Timeline.ClipHandle edge="end" />
<Timeline.ClipFadeHandle edge="in" />
<Timeline.ClipFadeHandle edge="out" />
</Timeline.Clip>
<Timeline.Playhead />
</Timeline.Lane>
</Timeline.Viewport>
<Timeline.Navigator>
<Timeline.NavigatorWindow />
<Timeline.NavigatorHandle edge="start" />
<Timeline.NavigatorHandle edge="end" />
</Timeline.Navigator>
</Timeline.Root>Usage
import {
Timeline,
TimelineLane,
TimelineMark,
TimelinePlayhead,
TimelineRuler,
TimelineViewport,
} from "@/components/audio-ui/timeline";
import {
DEFAULT_TEMPO,
describeBarsBeats,
gridInterval,
} from "@audio-ui/core/time";const snapStep = gridInterval("adaptive", zoom, DEFAULT_TEMPO);
<Timeline
duration={32}
onPositionChange={(seconds) => transport.seek(seconds)}
onZoomChange={setZoom}
snapStep={snapStep}
source={transport.position}
zoom={zoom}
>
<TimelineViewport>
<TimelineRuler
aria-label="Playhead"
getAriaValueText={(seconds) => describeBarsBeats(seconds, DEFAULT_TEMPO)}
>
<TimelineMark value={0}>1</TimelineMark>
<TimelineMark value={2}>2</TimelineMark>
<TimelinePlayhead />
</TimelineRuler>
<TimelineLane gridStep={snapStep}>
<TimelinePlayhead />
</TimelineLane>
</TimelineViewport>
</Timeline>Ranges
const [loop, setLoop] = useState<readonly [number, number]>([8, 16]);
<TimelineLane>
<TimelineRange aria-label="Loop" drawable onValueChange={setLoop} value={loop}>
<TimelineRangeHandle aria-label="Loop start" edge="start" />
<TimelineRangeHandle aria-label="Loop end" edge="end" />
</TimelineRange>
</TimelineLane>The range's own input moves it with Left and Right, by the timeline's step, and with Up and Down by its own length; Cmd/Ctrl + Left and Right shorten or lengthen it by a step, Cmd/Ctrl + Up and Down double or halve it; each handle's input moves its edge, which stops minLength short of the other one (the grid step by default). onValueChange receives [start, end] and eventDetails with the edge that moved ("start", "end" or "both") and the reason ("drag", "draw", "keyboard" or "input-change"). Leave the handles out for a range that only moves, or pass movable={false} for one that only resizes.
Tracks
A track is a row: a header beside a lane. Timeline.Track holds the row, Timeline.TrackHeader is the strip that names it and stays at the left edge while the lane scrolls, and Timeline.TrackResizeHandle sets its height. You hold the height: leave it out and the row shares the room it is given; set it and the row keeps it.
const [height, setHeight] = useState<number | undefined>();
<Timeline.Track
height={height}
onHeightChange={(next) => setHeight(next)}
selected={selected}
>
<Timeline.TrackHeader>
Drums
<Timeline.TrackResizeHandle aria-label="Height of Drums" />
</Timeline.TrackHeader>
<Timeline.Lane>{/* clips */}</Timeline.Lane>
</Timeline.Track>;You put the header's controls in it, such as solo, mute and gain. The track does not know about them.
The block reorders tracks by their grip, the bar at the track's left edge: press it and drag. A copy of the track's header follows the pointer, the dragged track stays in place faded, and a line marks the place it would land in. Near the top or bottom of a view that scrolls, the view scrolls on its own while the track is held. The arrow keys move a focused grip one place up or down, and every move is one undo step with its clips. The move is moveTrack(id, index) in useArrangement; it uses a pointer drag rather than the browser's drag and drop, so it does not depend on how the browser handles a drag.
Clips
A clip is a stretch of a source (a recording, a pattern) laid on a track. It is a range that knows about its source: trimming its start moves where in the source it begins, so its content stays where it is, and it cannot show more than the source holds.
const [clip, setClip] = useState<Timeline.Clip.Value>({
start: 8, // where it sits on the timeline
length: 16, // how long it plays
offset: 4, // where in the source it begins
fadeIn: 0,
fadeInShape: "linear", // or "fast", "slow", "smooth"
fadeOut: 0,
});
<Timeline.Lane data-track="drums" seekable>
<Timeline.Clip
aria-label="Drums, Groove"
onRemove={() => removeClip()}
onValueChange={setClip}
sourceLength={48}
value={clip}
>
<Timeline.ClipHeader>Groove</Timeline.ClipHeader>
<Timeline.ClipFade edge="in" />
<Timeline.ClipFade edge="out" />
<Timeline.ClipHandle aria-label="Groove start" edge="start" />
<Timeline.ClipHandle aria-label="Groove end" edge="end" />
<Timeline.ClipFadeHandle aria-label="Groove fade in" edge="in" />
<Timeline.ClipFadeHandle aria-label="Groove fade out" edge="out" />
</Timeline.Clip>
</Timeline.Lane>All of it is in the timeline's units, the source's too. Dragging the clip's header, its title bar, moves it on the grid, and over other lanes: eventDetails.lane is the Timeline.Lane under the pointer, so put your own attribute on each lane (data-track above) and read it in onValueCommitted, which also fires for a drop on another lane that left the position as it was. The clip follows the pointer vertically through --clip-drag-y. A press on the clip's body selects it and puts the cursor there, and a drag scrubs, so the body never moves the clip by accident. Its content is yours: it sets --clip-offset and --clip-source-length so a Waveform can show the stretch that is played.
Holding Alt when a drag begins marks it as a copy (eventDetails.copy): the clip you were dragging is the copy, so leave the original where it was and add the dragged one. A fade's shape is stepped by double-clicking its handle, or Enter on its input (reason is "double-click" or "keyboard"), and ClipFade draws the real ramp. The registry's TimelineClipFadeHandle also takes onShapeChange: a right-click on the handle lists the four curves, each drawn, and the arrangement's setFadeShape(id, edge, shape) sets one as a single undo step.
Laying a clip where others already are is coverClips(clips, placed, { duration }, split): the clips under it are covered rather than stacked (removed, trimmed or cut in two around it), and split makes the second piece of a cut clip from the first, so what your app keeps about a clip travels with each piece. Covering only shortens a clip, so the timeline's duration is the one limit it reads: the length of a source, which limits how far one clip stretches, never applies to the clips under it.
Cutting, copying and the like are plain functions on the clip, for the model behind your clips: splitClip(clip, at) returns the two pieces (or null when at leaves one too short), and moveClip, trimClipStart, trimClipEnd, setClipFadeIn, setClipFadeOut, setClipFadeShape and duplicateClip return the clip as it would end up, within the limits you give them.
Many clips
Dragging one clip among 1 000 takes about a millisecond of script, and the playhead moves outside React, so it costs no render. The real cost is rendering every clip again whenever one moves, which happens when the clip component is not memoized. Put one clip in a React.memo component with stable props (the clip's own value, callbacks that take its id), so React skips every clip a drag did not touch.
Selecting several clips
The primitive keeps no selection: selected and onSelectedChange are yours, so a selection is any set of ids you keep. onSelectedChange fires when a press selects a clip, and again on a press with Shift, Cmd or Ctrl held (read eventDetails.event), which is how you add to a selection or take from it. A touch screen has no modifier keys: give the clip additive (from a "select several" mode of your own) and every press is reported, so a press adds a clip to the selection or takes it from it. To move a group, read eventDetails.delta on a move, how far the dragged clip is from where the drag began, and move each other selected clip by the same amount from where it was when the drag began; moveClip keeps each on the timeline. For the group to follow the clip up and down too, onDragOffsetChange reports how far it is dragged vertically, in px, and 0 when the drag ends: shift the other selected clips by that much. A drop on another lane then moves each by as many lanes as the dragged one did. The registry's arrangement block does all of this, with Cmd/Ctrl + A to select every clip and Shift + drag across bare lane to select the clips a rectangle touches (it measures each clip's getBoundingClientRect(), so it needs nothing from the primitive).
The cursor
A Timeline.Lane with seekable puts the position where a press lands on its bare area, as the ruler does, and a drag scrubs it. Presses on the clips in it go to the clips. The cursor can then go anywhere along a track, and your track header selects the whole track. A split is then splitClip(clip, position) on the selected clip, or on each clip of the selected track that the cursor is over.
Navigator
<Timeline zoom={zoom} onZoomChange={setZoom} duration={32}>
<TimelineViewport scrollbar="hidden">…</TimelineViewport>
<TimelineNavigator />
</Timeline>Place it anywhere inside the timeline, outside the viewport. Dragging an edge zooms while holding the other edge in place, through onZoomChange with reason: "drag"; a double-click fits the whole timeline with reason: "double-click".
Musical time
@audio-ui/core/time turns a tempo into what a timeline needs, in seconds:
gridInterval(division, zoom, tempo)is the grid's step: a fixed division ("1-bar","1/8", …), or"adaptive"for the finest division still 24px apart at this zoom. Pass{ triplet: true }for three in the space of two. Give the same number tosnapStepand to the lanes'gridStep, so gestures land on the lines you see.barLabelInterval(zoom, tempo)is how far apart the ruler's bar numbers go: whole bars, spread out until they no longer collide.barsBeatsAt(seconds, tempo)anddescribeBarsBeatsread a position as bars, beats and sixteenths.gridLabel(seconds, tempo)names an interval the way a grid readout does ("1 bar","1/16","1/8T"), so the adaptive grid reads out the division it uses, and a menu and a readout always name a grid the same way.metronomeRhythmSeconds(rhythm, tempo)is the interval of a click rhythm ("1/8","1/8t", …), ornullfor"auto", which follows the beat.
Placing your own parts
Every lane sets a --timeline-unit CSS variable, the width of one unit. Parts are placed with it, so anything you draw follows the zoom the same way:
<div style={{ left: "calc(var(--timeline-unit) * 12)", position: "absolute" }} />The arrangement
The parts on this page hold the arrangement and fill in what a DAW's arrangement needs around the timeline.
The state
arrangement(npx shadcn@latest add @audio-ui/arrangement) is the document: the types of a clip, a track and a mix, and what reads them.clipReachis how far a clip can stretch,playedNoteswhere a MIDI clip's notes play, andmixAtwhat a track plays at a point of the song. Its edits ship withuse-arrangement, inlib/arrangement-state:layputs a clip on a track so it covers what is under it,landdrops a dragged group (or its copies) on another track,shiftTrackandotherSelectedare what a group drop needs, andscaleValuerescales a clip when the tempo changes. Tracks are passed as an order of ids, so nothing assumes how many there are.use-arrangementis the state: tracks, clips, selection, each track's mix, its height, the tempo, the time signatures, the locators and the loop, and every operation on them. It keeps undo and redo over all of them together.use-simulated-clockis a clock for a timeline that makes no sound, as a live source that loops between two points (to start sound at a position,use-transport-clockschedules against the audio clock), anduse-marquee-selectionis the rectangle that selects the clips it touches.
const arrangement = useArrangement({
duration, // the length of the song in seconds when it starts; a change of tempo scales it
initialClips,
initialLocators, // { at, name }[], in seconds
initialLoop, // { on, range: [start, end] }
initialTracks, // { id, name, color, mix? }[]
tempo, // { bpm, beatsPerBar, beatUnit }
});
arrangement.clips; // what the arrangement holds, with tracks, tempo, signatures, locators, loop and duration
arrangement.dispatch({ type: "add-track" }); // every change is a command; it returns the state after it
arrangement.dispatch({ type: "undo" });The state is a plain value and a change is a command of ArrangementCommand, which lib/arrangement-state's apply(state, command) takes: a pure function, so the arrangement runs, and is tested, with no React. The hook holds the state and returns it with dispatch, which returns the state after the command. A command that changes nothing returns the same state and adds no step to the history.
- Clips:
add-clip,change-clip(whatTimeline.Clipreports on a drag, byid),commit-clip(lands the drag, on another track too),edit-clip,remove-clip,rename-clip,set-fade-shape(a fade's curve, as one undo step),crop-clip,clip-action(a clip's menu: duplicate, split, switch off, delete),duplicate-selectionandsplit-cursor(atand the shortest piecestepcome with the command, since the host knows the playhead). A drag is a burst thatsettle-clip-editcloses into one step. - Selection:
select-clip(id,additive),select-clips,select-all,select-track(id,additive),clear-selectionandtoggle-selected. The hook returnsselectedIds,selectedTracksandselection. - Tracks:
add-track,remove-track,rename-track,duplicate-track(the track with its clips and its mix, placed after it),move-track,set-mix(a switch: mute, solo) andchange-mix(a drag of the gain or the pan, closed bysettle-clip-edit), andset-track-color. A track's mix is itsmix, so it is undoable;mixOf(track)is what a header reads.heights,resizeTrack,foldedandtoggleFoldstay in the hook, since they are the view and not the document.copyOf(time)is a read, not a command: what a copy would hold. - Time:
clear-time,paste,remove-time,insert-time,consolidate-timeandfade-timeact on a time selection ({ range, tracks }), andcopyOf(time)copies one;lib/arrangement-stateholds them as pure functions:copyTime,clearTime,pasteTime,deleteTime,openTime,consolidate, withcrossfadefor a fade where two clips meet, given to both. - Tempo, signatures, locators and the loop:
set-tempokeeps the musical positions, so every time in the document is scaled to the new bar and a clip three bars long stays three bars long, as one undo step, since each step of the history carries its own tempo. Thenadd-signature,set-signature,move-signatureandremove-signature,add-locator,rename-locatorandremove-locator,set-loop({ on, range }, either one) andchange-loop, a drag of the range that is one step oncesettle-clip-editcloses it, andset-duration, which changes the song's length without a step. The hook returnstempo,signatures,locators,loopandduration. - History:
undoandredo, andcanUndoandcanRedo.
Arranged clips
ArrangedClip (npx shadcn@latest add @audio-ui/arranged-clip) is a clip of the arrangement on its track's lane: drag its title bar to move it (with Alt, a copy), its edges to trim it into its source, its top corners to fade it, and pick a fade's curve from the menu of its handle. Double-click its title to rename it in place; right-click it for its menu (edit, rename, duplicate, split at the cursor, switch off, delete), which selects it first. A MIDI clip draws its notes across its body, and a looped clip has no end while a warped one lasts as long as its warp stretches its source: clipReach(clip) in lib/arrangement gives how far a clip can be stretched, undefined when it loops.
import { ArrangedClip, ArrangedClipGhost } from "@/components/audio-ui/arranged-clip";
<TimelineLane>
{clips
.filter((clip) => clip.track === track.id)
.map((clip) => (
<ArrangedClip
clip={clip}
describe={describe}
key={clip.id}
onAction={(action, id) =>
arrangement.dispatch({
type: "clip-action",
action,
id,
at: clock.getSnapshot(),
step: bar / 4,
})
}
onChange={(id, value, details) =>
arrangement.dispatch({ type: "change-clip", id, value, details })
}
onCommit={(id, details) =>
arrangement.dispatch({
type: "commit-clip",
id,
copy: Boolean(details.copy),
track: details.lane instanceof HTMLElement ? details.lane.dataset.track : undefined,
})
}
onFadeShape={(id, edge, shape) =>
arrangement.dispatch({ type: "set-fade-shape", id, edge, shape })
}
onRemove={(id) => arrangement.dispatch({ type: "remove-clip", id })}
onRename={(id, name) =>
arrangement.dispatch({ type: "rename-clip", id, name })
}
onSelect={(id, additive) =>
arrangement.dispatch({ type: "select-clip", id, additive })
}
selected={arrangement.selectedIds.includes(clip.id)}
swatch={arrangement.mixOf(track).color}
trackName={track.name}
/>
))}
</TimelineLane>;While a clip is dragged with Alt, arrangement.copying lists the originals: draw an ArrangedClipGhost for each, where it stays. lift and onLift move the rest of a selection up or down with the clip being dragged across tracks.
With snapping on, a dragged clip or loop also lands on the edges of the other clips, the locators and the loop, through snapPoints. Where two clips meet, the fade-out of one and the fade-in of the next move together, as a crossfade, and a dashed line shows how far a fade being dragged can go.
Arranged tracks
A track's header is TimelineTrackControls, from the timeline: its name, gain, pan, mute, solo, color, fold and menu. useTrackReorder (npx shadcn@latest add @audio-ui/use-track-reorder) moves tracks: give each track a grip, and drag the grip, or press Up or Down on it.
import { useTrackReorder } from "@/hooks/use-track-reorder";
const reorder = useTrackReorder({
onMove: arrangement.moveTrack, // (id, index)
order: tracks.map((track) => track.id),
});
<button aria-label={`Move ${track.name}`} type="button" {...reorder.gripProps(track.id)}>
⋮⋮
</button>;The grip sits in its track's row, inside the Timeline. While a track is dragged, draggingId is the track and drop ({ id, edge }) is the edge of the row it would land on, to draw a line there; ghost is the box of its header, and ghostRef goes on the copy you draw under the pointer, fixed, which the drag moves without a render. The view scrolls when the pointer nears its top or bottom edge.
Locators and time signatures
TimelineLocator and TimelineSignature (npx shadcn@latest add @audio-ui/timeline-markers) go in a TimelineMark on a lane of their own. A locator is a named point: press it to put the cursor there, double-click it or press Enter to rename it, Delete to take it away. The block's M adds one at the cursor, and Alt with the arrows jumps from one to the next.
import { TimelineLocator, TimelineSignature } from "@/components/audio-ui/timeline-markers";
<TimelineLane>
{locators.map((locator) => (
<TimelineMark key={locator.at} value={locator.at}>
<TimelineLocator
locator={locator}
onJump={seek}
onRemove={removeLocator}
onRename={renameLocator}
/>
</TimelineMark>
))}
</TimelineLane>;The Signature strip holds time-signature changes: its + adds one at the cursor, in the signature already there, ready to be typed (such as 7/8). From a change on, bars are counted in its signature: the ruler's numbers, the hatched partial bar a change cuts short, and the position in the transport all follow, through barsOf and barsBeatsWith from @audio-ui/core/time. On a change, Left and Right move it by a grid step, Enter edits it and Delete takes it away. The grid's bar-long divisions and the tempo stay those of the song's start. Which change is being typed is yours to hold (editing and onEditingChange), so a change just added opens ready to type. parseSignature("7/8") reads what is typed.
Automation
Press A, or the automation button, for automation mode: each track's header gets a chooser of what its lane shows, Volume or Pan, and its lane the envelope of that parameter over its clips (automation-envelope, npx shadcn@latest add @audio-ui/automation-envelope). Until the track has one, the line stands faint at the mix's value; double-click it to add a point, drag a point to move it on the grid (its level stays free), drag the small handle on a segment to bend it, and double-click a point to take it away. Beside the line, a press reaches the lane and the clips as before. A MIDI track's channel follows its envelopes as it plays, glided ahead on the audio clock. Moving an automated gain or pan by hand overrides it: the envelope greys, the mix plays, and Re-enable automation in the toolbar (or drawing on the envelope) brings it back. In lib/arrangement, a track keeps its automation per parameter as points across the whole arrangement, x from 0 to 1, so a tempo change keeps them in place; automationAt reads one, volumeGain and panPosition turn its values into a gain and a pan, and mixAt gives what a track plays at a point of the song.
TrackAutomationChooser and TrackAutomation (npx shadcn@latest add @audio-ui/track-automation) are those two parts: the chooser goes in the track's header (TimelineTrackControls takes it as children), the envelope in its lane. AUTOMATED_PARAMETERS lists what a track automates, Volume and Pan.
MIDI clips
A track can also hold MIDI: Bass and Keys are MIDI tracks, and + MIDI under the tracks adds one. A MIDI clip draws its notes across its body and, picked, opens on a piano roll in the clip panel (midi-clip-panel, npx shadcn@latest add @audio-ui/midi-clip-panel), with the same strip and Clip and Loop boxes (Loop adds Dupl. Loop, which plays the loop's notes again after it) and a Notes box (transpose, invert, reverse, legato, ×2, ÷2, humanize and velocity ramps, on the selected notes or every note): draw notes a grid step at a time, drag the loop brace on its ruler, move, stretch and edit them as in the piano roll, and each edit is one undo step of the arrangement. Double-click a bare MIDI lane for an empty clip a bar long, which loops as it is stretched. Unlike the recordings, MIDI clips are heard: while the arrangement plays, their notes go to a synth, scheduled ahead on the audio clock, on the tracks that are not muted or outside a solo, each MIDI track through a channel of its own that its header's gain and pan set. In lib/arrangement, a track's kind is "audio" or "midi", a MIDI clip keeps its notes in its source's time, and playedNotes gives where they play through the clip's offset and loop.
useMidiPlayback (npx shadcn@latest add @audio-ui/use-midi-playback) plays them. Give it the clips, the tracks, the arrangement's clock and whether it plays; audible says whether a track is heard and levelsAt gives a track's gain and pan at a time, from its mix or its automation. It returns onNoteOn and onNoteOff for a MIDI clip's editor, which sound what it auditions. Its voices and its bus are the Audio engine's Synthesis and Output; with an engine of your own, leave the hook out and play the clips from playedNotes.
The clip panel
Under the tracks, the clip panel (clip-panel, npx shadcn@latest add @audio-ui/clip-panel) shows the clip you pick, as a detail view follows the selection. It starts closed, and a press that picks a clip, trims it, fades it or moves it does not open it; its × hides it. It opens on a click of a clip's head, a double-click on a clip's body, the clip menu's Edit, or Cmd / Ctrl + Alt + 3, but not on a press that moves, trims or fades a clip, so you can work on a clip with the panel closed. Laid out as a DAW's clip view (clip-view holds the frame), a strip along its top carries the caret that rolls the panel up to that strip, the clip's switch, its name (type to rename it) and a word on it; under it, its properties sit in boxes beside the editor: Clip (where it starts and ends in its source), Loop (the switch, where the loop sits and how long it is, ÷2 and ×2) and Sample (Warp, Reverse, Crop, Gain and Transpose), all in bars.beats.16ths you can type or step with the arrow keys. Loop and Sample fold to a strip from their heading and give their width to the editor; what is rolled up or folded stays so from clip to clip, and the panel stays at the bottom of the block as the tracks scroll. Its source has a timeline of its own: an overview bar to zoom and scroll with, a ruler in bars, the stretch the clip plays to drag at both ends, or with Loop on the stretch that repeats (a looped clip can then be stretched past its source on the timeline), and the waveform under them. With Warp on, the source is stretched between markers that pin a time in it to a time on the clip: drag a marker, or press its arrows, to stretch what is on either side; double-click the waveform to add one, Delete to take one away, and ×2 or ÷2 to play the whole of it slower or faster. It plays by speed alone, so pitch follows (shown as a rate and its semitones), and a warped clip lasts as long as its warp stretches its source on the timeline. The map is warpedTime, unwarpedTime, warpRate and the marker functions in @audio-ui/core/time. The block makes no sound, so these are kept on the clip (gain, transpose, reversed, loop, warp, sourceFrom in lib/arrangement) and its waveform is a stand-in.
Keyboard
A drag across the bare lanes selects a stretch of time on the tracks it sweeps, drawn over them; the time commands below work on it. The block's own keys work once a press on a lane has put the focus in it:
Keys · 20
Home and End go to the start and to the end of the last clip.Left / Right move it by a grid step, Up / Down by its own length; with Cmd / Ctrl, Left / Right shorten or lengthen it and Up / Down double or halve it.Undo and redo
The undo-history lib (npx shadcn@latest add @audio-ui/undo-history) implements undo and redo as values: every function takes the present and returns the new present, and keeps no state of its own. Keep the history in a ref and the document in state, so two undos in one tick are fed the first one's result instead of reading the same stack twice.
let history = emptyHistory<Clip[]>();
// A change made in one go: delete, split, duplicate.
history = recordChange(history, clips);
// A drag reports every tick: open a burst on the first, close it on commit,
// and the whole drag is one entry.
history = beginChange(history, clips);
history = settleChange(history);
const step = undo(history, clips); // { history, present } or nullA new change clears redo, since the redo branch no longer follows from the present. HISTORY_LIMIT bounds the depth. The arrangement block uses it for its undo and redo buttons and for Cmd/Ctrl + Z, with Shift (or Y) to redo.
API reference
Root
Groups the parts of a timeline and owns what they share: its length, its zoom, its grid and the playback position. Units are yours: seconds, beats, pulses or steps. zoom is how many px one unit takes; leave it unset and the timeline fits the width it is given. The position comes from position, or from a live source that the playhead follows straight in the DOM without re-rendering React. With a snapStep, every gesture lands on the grid unless Cmd/Ctrl is held, and snapPoints (the edges of other clips, the locators) pull a dragged clip or range onto them from a few px away. Renders a <div> element.
Props · 17
steps.eventDetails.cancel() keeps the zoom where it was.duration.position.snapStep, or a hundredth of duration.Data attributes · 3
data-disableddata-fitteddata-scrubbingNavigator
The whole timeline as one bar, with the stretch the viewport shows drawn inside it as Timeline.NavigatorWindow: drag the window to scroll, drag one of its Timeline.NavigatorHandles to zoom, double-click to show everything. Place it anywhere inside Timeline.Root, outside the viewport. Renders a <div> element.
Props · 1
Data attributes · 3
data-disableddata-fitteddata-scrubbingNavigatorWindow
The stretch of the timeline the viewport shows. Dragging it scrolls; its native <input type="range"> scrolls from the keyboard and carries its accessible name. Renders a <div> element.
Data attributes · 3
data-disableddata-fitteddata-scrubbingNavigatorHandle
One edge of the window: dragging it zooms, holding the other edge in place. Its native <input type="range"> does the same from the keyboard and carries its accessible name. Renders a <div> element.
Props · 1
Data attributes · 3
data-disableddata-fitteddata-scrubbingViewport
The scrolling window onto the timeline. Cmd/Ctrl + wheel (and pinch on a trackpad) zooms around the pointer; a bare wheel scrolls as usual. Every zoom change keeps its anchor point in place, or the middle of the view when there is none. It reports its scroll offset and width for Timeline.Navigator. Renders a <div> element.
Data attributes · 3
data-disableddata-fitteddata-scrubbingLane
A strip along the timeline that its parts are placed on: a track, the ruler's strip, a row of markers. Zoomed, it is as long as the timeline; fitted, it takes the width it is given and the timeline is spread across it. Its --timeline-unit CSS variable is the width of one unit. With seekable, a press on its bare area moves the position, as on the ruler, so the cursor can be put anywhere along a track; with onSelectionChange too, a drag from there selects a stretch of time instead of scrubbing. Renders a <div> element.
Props · 3
seekable, fires while a drag from the lane's bare area selects a stretch of time, as [start, end] in timeline units on the grid unless Cmd/Ctrl is held: from where the press put the position to the pointer. The drag then no longer scrubs. eventDetails.cancel() keeps the selection as it was.Data attributes · 3
data-disableddata-fitteddata-scrubbingTrack
One track of the arrangement: a row holding a header beside a lane. Renders a <div> element.
Props · 6
data-selected.undefined. eventDetails.cancel() refuses it.Data attributes · 2
data-selecteddata-resizingTrackHeader
The strip beside a track's lane that names it and holds what sets it. It stays at the left edge while the lane scrolls under it, at the stacking level of the --timeline-track-header-z CSS variable (20): raise it while the header is hovered or focused if it shows something that reaches into the next row, such as a value readout. It carries the track's data-selected and data-resizing. Renders a <div> element.
TrackResizeHandle
Sets the track's height: by dragging, or from the keyboard through the native <input type="range"> it holds, which carries the accessible name. The arrows move by a step (Down is taller), Page Up and Page Down by a large one, Home and End to the minimum and the maximum. Place it along the track's lower edge. A double-click clears the height, so the track shares the room again. Renders a <div> element.
Ruler
The lane that moves the position: a press jumps to the pointer and a drag scrubs, both on the grid unless Cmd/Ctrl is held; with Shift held the drag scrubs four times finer. With onSelectionChange, a drag that starts with Shift held selects a stretch instead, to loop. It holds the native <input type="range"> that takes focus, handles the keyboard and carries the accessible name (aria-label / aria-labelledby). Place Timeline.Marks in it for its labels. Renders a <div> element.
Props · 3
"bar 3, beat 2". Defaults to the number.[start, end] in timeline units on the grid unless Cmd/Ctrl is held: feed it to a Timeline.Range as a loop. A plain press and drag still move the position. eventDetails.cancel() keeps the selection as it was.Data attributes · 3
data-disableddata-fitteddata-scrubbingRange
A stretch of the timeline, such as a loop, a selection or a clip, laid on the lane it sits in from value[0] to value[1]. Dragging its body moves it, on the grid unless Cmd/Ctrl is held; add a Timeline.RangeHandle per edge to resize it. With drawable, dragging across empty lane draws a new range. Its own native <input type="range"> moves it from the keyboard and carries its accessible name (aria-label / aria-labelledby): Left and Right by a step, Up and Down by its own length, and with Cmd/Ctrl, Left and Right shorten or lengthen it by a step while Up and Down double or halve it. A drag lands an edge on a snapPoints place within a few px. Renders a <div> element.
Props · 9
[start, end] in the timeline's units.Data attributes · 2
data-disableddata-draggingRangeHandle
Resizes one edge of the range: by dragging, on the grid unless Cmd/Ctrl is held, or from the keyboard through the native <input type="range"> it holds, which carries the accessible name. Renders a <div> element.
Props · 1
Data attributes · 2
data-disableddata-draggingClip
A stretch of a source laid on a lane. Dragging its Timeline.ClipHeader moves it, on the grid unless Cmd/Ctrl is held, and over other lanes too; pressing its body selects it and puts the cursor there. Add a Timeline.ClipHandle per edge to trim it and a Timeline.ClipFadeHandle per fade. It sets --clip-offset, --clip-source-length, --clip-fade-in and --clip-fade-out for its content, and --clip-fade-in-max and --clip-fade-out-max, how long each fade can grow, to draw the limit of a fade being dragged (data-fading). Its own native <input type="range"> moves it from the keyboard and carries its accessible name (aria-label / aria-labelledby); Delete and Backspace on it call onRemove. Renders a <div> element.
Props · 14
{ start, length, offset, fadeIn, fadeOut }, all in the timeline's units.selected is not set.additive), which eventDetails.event carries.Data attributes · 4
data-disableddata-draggingdata-fading"in" or "out": the fade a pointer is dragging.data-selectedClipHeader
The clip's title bar: dragging it moves the clip, on the grid unless Cmd/Ctrl is held, and over other lanes too. The rest of the clip is its body, where a press puts the cursor. Place it along the clip's top and put the clip's name in it. Renders a <div> element.
Data attributes · 3
data-disableddata-draggingdata-selectedClipHandle
Trims one edge of the clip: by dragging, on the grid unless Cmd/Ctrl is held, or from the keyboard through the native <input type="range"> it holds, which carries the accessible name. Trimming the start moves where in the source the clip begins and leaves its content where it is; trimming the end stops where the source or the timeline does. Renders a <div> element.
Props · 1
ClipFadeHandle
Sets how long one of the clip's fades lasts: by dragging, or from the keyboard through the native <input type="range"> it holds, which carries the accessible name. Double-clicking it, or pressing Enter on its input, steps the fade through its shapes, and Delete or Backspace takes the fade away. It sits where its fade ends, at the clip's top edge. The two fades cannot overlap. Renders a <div> element.
Props · 1
ClipFade
Draws a fade over the clip's content, as a DAW does: the ramp as a line from the clip's edge, as long as the fade, with what it attenuates lightly shaded. It is hidden while its fade is empty and ignores the pointer. Its color is the color it is given. Renders an <svg> element.
Props · 1
Playhead
A marker at the playback position, on the lane it sits in. It follows the position straight in the DOM, through its own --timeline-offset CSS variable, so a playhead moving every frame never re-renders React. Renders a <div> element.
Data attributes · 3
data-disableddata-fitteddata-scrubbingMark
Something placed at value on a lane: a bar number on the ruler, a cue, a grid line. Its children are its label. Renders a <div> element.
Props · 1
Data attributes · 3
data-disableddata-fitteddata-scrubbingRegistry component
Timeline
Props · 14
eventDetails.cancel() keeps the zoom where it was.duration.position.Parts
Components · 16
TimelineViewportscrollbar="hidden" drops the native scrollbar.TimelineRuleraria-label and getAriaValueText.TimelineLanegridStep draws a line every that many units.TimelineTrackheight, selected, onHeightChange.TimelineTrackHeaderTimelineTrackResizeHandleTimelineMarkvalue: a bar number, a cue.TimelinePlayheadTimelineRangevalue, defaultValue, minLength, movable, drawable, onValueChange, onValueCommitted.TimelineRangeHandleedge of the range.TimelineClipvalue, sourceLength, selected, onValueChange, onValueCommitted, onRemove. Dragged over other lanes too.TimelineClipHeaderTimelineClipHandleedge of the clip.TimelineClipFadeedge.TimelineClipFadeHandleedge.TimelineNavigator@audio-ui/react/timeline exposes the same parts as Timeline.Root, Timeline.Viewport, Timeline.Ruler, Timeline.Lane, Timeline.Mark, Timeline.Playhead, Timeline.Range, Timeline.RangeHandle, Timeline.Clip, Timeline.ClipHeader, Timeline.ClipHandle, Timeline.ClipFade, Timeline.ClipFadeHandle, and Timeline.Navigator with its Timeline.NavigatorWindow and Timeline.NavigatorHandle. Every part accepts a render prop and className / style as functions of state, and exposes data-disabled, data-fitted and data-scrubbing.
ArrangedClip
Props · 11
value), its source and its notes.TRACK_COLORS: its track's.additive with Shift, Cmd or Ctrl, or with multi."edit", "duplicate", "split", "toggle" or "remove".useTrackReorder
Props · 5
index.TimelineLocator and TimelineSignature
Props · 6
step with the arrows, typed, deleted.TrackAutomationChooser and TrackAutomation
Props · 7
automation.useMidiPlayback
Props · 7