Arrangement
Transport and mixer
EQ and filters
Modulation
Utility
Instruments
Audio UI
The base for an arrangement view, a clip editor or a piano roll. 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.
Installation
pnpm dlx shadcn@latest add @audio-ui/timeline npm install @audio-ui/core
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.
An arrangement
The arrangement block, the preview above, is a timeline with tracks of clips, locators, time signatures, automation and MIDI clips. Its parts, its state, its keys and its undo are on the Arrangement editor page.
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" }} />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.
More Shadcn Timeline components
Browse 1 production-ready audio/ui Timeline components for players, mixers, and synth interfaces. These examples use the headless primitives from @audio-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.
Browse all 1 Shadcn Timeline components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the audio/ui library.