Timeline
Time laid out left to right: tracks of clips under a ruler, with a playhead.
'use client';
import { PauseIcon, PlayIcon, Redo2Icon, ScanSearchIcon, Undo2Icon, VideoIcon } from 'lucide-react';
import { useState } from 'react';
import { Timeline, useTimeline } from '@/components/panelui/timeline';
import { Toolbar } from '@/components/panelui/toolbar';
type Span = { id: string; start: number; end: number };
const CUTS: Span[] = [
{ id: 'c1', start: 4, end: 21 },
{ id: 'c2', start: 24, end: 38 },
{ id: 'c3', start: 52, end: 61 },
{ id: 'c4', start: 77, end: 96 },
{ id: 'c5', start: 99, end: 108 },
{ id: 'c6', start: 121, end: 139 },
];
const ZOOMS: Span[] = [
{ id: 'z1', start: 15, end: 22.5 },
{ id: 'z2', start: 63, end: 70 },
{ id: 'z3', start: 112, end: 121.4 },
];
/** Loudness for speech: phrases with breaths between them. Seeded, so every render draws the same. */
function speech(count: number, seed: number) {
let state = seed;
const random = () => (state = (state * 16807) % 2147483647) / 2147483647;
const peaks: number[] = [];
for (let at = 0, left = 0, phrase = 1, pause = false; at < count; at++, left--) {
if (left <= 0) {
pause = !pause && random() < 0.35;
left = phrase = pause ? 2 + Math.floor(random() * 4) : 10 + Math.floor(random() * 22);
}
const swell = Math.sin(Math.PI * (1 - left / phrase)) * 0.55 + 0.3;
peaks.push(pause ? 0.04 + random() * 0.05 : Math.min(1, swell * (0.55 + random() * 0.6)));
}
return peaks;
}
const PEAKS = speech(260, 7);
/** The play button reads and drives the timeline it sits in. */
function Transport() {
const { playing, setPlaying } = useTimeline();
return (
<Toolbar variant="inline" size="sm" aria-label="Playback">
<Toolbar.Button label={playing ? 'Pause' : 'Play'} shortcut="Space" onClick={() => setPlaying(!playing)}>
{playing ? <PauseIcon /> : <PlayIcon />}
</Toolbar.Button>
<Toolbar.Separator />
<Toolbar.Button label="Undo" shortcut="⌘Z">
<Undo2Icon />
</Toolbar.Button>
<Toolbar.Button label="Redo" shortcut="⇧⌘Z" disabled>
<Redo2Icon />
</Toolbar.Button>
</Toolbar>
);
}
export default function TimelineOverview() {
const [cuts, setCuts] = useState(CUTS);
const [zooms, setZooms] = useState(ZOOMS);
const [take, setTake] = useState({ start: 0, end: 128 });
const update = (set: typeof setCuts, id: string) => (span: { start: number; end: number }) =>
set((all) => all.map((each) => (each.id === id ? { ...each, ...span } : each)));
return (
<Timeline duration={140} defaultTime={58} defaultZoom={7}>
<Timeline.Header>
<Transport />
<Timeline.Time />
<Timeline.Zoom />
</Timeline.Header>
<Timeline.Viewport>
<Timeline.Ruler />
<Timeline.Track size="sm" label="Cuts">
{cuts.map((cut) => (
<Timeline.Clip key={cut.id} label="Cut" color="warning" start={cut.start} end={cut.end} onChange={update(setCuts, cut.id)} />
))}
</Timeline.Track>
<Timeline.Track label="Zooms">
{zooms.map((zoom) => (
<Timeline.Clip key={zoom.id} label="Zoom" color="chart-2" start={zoom.start} end={zoom.end} onChange={update(setZooms, zoom.id)}>
{zoom.end - zoom.start >= 9 ? (
<Timeline.ClipLabel>
<ScanSearchIcon />
{(zoom.end - zoom.start).toFixed(1)}s
</Timeline.ClipLabel>
) : null}
</Timeline.Clip>
))}
</Timeline.Track>
<Timeline.Track size="lg" label="Recording">
<Timeline.Clip label="Take 3" color="info" start={take.start} end={take.end} onChange={setTake}>
<Timeline.Waveform peaks={PEAKS} />
<Timeline.ClipLabel>
<VideoIcon />
Take 3 · {Math.floor((take.end - take.start) / 60)}m {String(Math.round((take.end - take.start) % 60)).padStart(2, '0')}s
</Timeline.ClipLabel>
</Timeline.Clip>
</Timeline.Track>
<Timeline.Playhead />
</Timeline.Viewport>
</Timeline>
);
}Use it to edit or show things placed in time: the cuts and zooms of a screen recording, the tweens of an animation, the voices of a podcast. Tracks of clips sit under a ruler, and a playhead marks the current moment.
A clip given onChange can be moved by its middle and trimmed by either end. It stops at its neighbours on the same track, and snaps to other clips' edges, the playhead and the ends, with a guide line while it does.
It needs a width to lay time across; the height follows the tracks. For a value that moves along a range without clips, use a Slider.
Installation
npx shadcn@latest add https://panelui.dev/r/web/timeline.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/timeline.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/timeline.jsonbunx shadcn@latest add https://panelui.dev/r/web/timeline.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport from panelui-web/timeline instead of @/components/panelui/timeline. Tailwind needs one line to read the package's classes, which is on the installation page.
Install the packages it uses:
npm install @base-ui/react tailwind-variantspnpm add @base-ui/react tailwind-variantsyarn add @base-ui/react tailwind-variantsbun add @base-ui/react tailwind-variantsAdd the component it is built on: Slider.
Copy the source into components/panelui/timeline.tsx:
Usage
import { Timeline } from "@/components/panelui/timeline"<Timeline duration={140} defaultZoom={7}>
<Timeline.Header>
<Timeline.Time />
<Timeline.Zoom />
</Timeline.Header>
<Timeline.Viewport>
<Timeline.Ruler />
<Timeline.Track label="Zooms">
<Timeline.Clip color="chart-2" start={zoom.start} end={zoom.end} onChange={setZoom} />
</Timeline.Track>
<Timeline.Track size="lg" label="Recording">
<Timeline.Clip color="info" start={0} end={128}>
<Timeline.Waveform peaks={peaks} />
<Timeline.ClipLabel>Take 3</Timeline.ClipLabel>
</Timeline.Clip>
</Timeline.Track>
<Timeline.Playhead />
</Timeline.Viewport>
</Timeline>Composition
<Timeline>
<Timeline.Header>
<Timeline.Time />
<Timeline.Zoom />
</Timeline.Header>
<Timeline.Viewport>
<Timeline.Ruler />
<Timeline.Track>
<Timeline.Clip>
<Timeline.Waveform />
<Timeline.ClipLabel />
</Timeline.Clip>
</Timeline.Track>
<Timeline.Playhead />
</Timeline.Viewport>
</Timeline>Timeline.Header— A row above the tracks, for a toolbar, the time and the zoom.useTimeline()gives a toolbar placed here the time, playback and zoom.Timeline.Time— The playhead's time, in minutes, seconds and tenths, and the length after it.Timeline.Zoom— A line Slider between zoom out and zoom in. It runs on a log scale, so each stretch of it zooms by the same factor.Timeline.Viewport— The part that scrolls sideways, holding the ruler, the tracks and the playhead. Pinch, or ⌘ or Ctrl with the wheel, zooms about the pointer. A strip of diagonal lines marks the time past the end.Timeline.Ruler— Ticks and times along the top. Its labels stay at least 64 pixels apart at any zoom. Pressing or dragging on it moves the playhead.Timeline.Track— A row of clips that cannot overlap.sizesets its height:smfor bars,mdfor blocks,lgfor media.labelnames it for a screen reader.Timeline.Clip— One clip fromstarttoend, in seconds.variantisbar,blockormedia, and follows the track's size unless set.colortakes a theme colour. WithonChangeit can be moved and trimmed, andonRemoveis called on Delete.Timeline.ClipLabel— Words on a clip, with an icon: its length, its name, its speed. A block with a label leaves out its grips.Timeline.Waveform— Loudness across a media clip, frompeaksbetween 0 and 1, drawn mirrored about the middle.Timeline.Playhead— The line at the current time. Its head shows the time and can be dragged, and with focus the arrow keys step it and Space plays and pauses.
Examples
Voices
Three tracks of media clips, one per speaker, each in a chart colour so a voice can be followed along the whole episode. Each clip draws its own waveform.
'use client';
import { useState } from 'react';
import { Timeline, type TimelineColor } from '@/components/panelui/timeline';
/** Loudness for speech: phrases with breaths between them. Seeded, so every render draws the same. */
function speech(count: number, seed: number) {
let state = seed;
const random = () => (state = (state * 16807) % 2147483647) / 2147483647;
const peaks: number[] = [];
for (let at = 0, left = 0, phrase = 1, pause = false; at < count; at++, left--) {
if (left <= 0) {
pause = !pause && random() < 0.35;
left = phrase = pause ? 2 + Math.floor(random() * 4) : 10 + Math.floor(random() * 22);
}
const swell = Math.sin(Math.PI * (1 - left / phrase)) * 0.55 + 0.3;
peaks.push(pause ? 0.04 + random() * 0.05 : Math.min(1, swell * (0.55 + random() * 0.6)));
}
return peaks;
}
const VOICES: { name: string; color: TimelineColor; parts: [number, number][]; seed: number }[] = [
{ name: 'Mariam', color: 'chart-1', parts: [[0, 34], [61, 96], [140, 172]], seed: 11 },
{ name: 'Joel', color: 'chart-2', parts: [[33, 62], [97, 118], [171, 200]], seed: 29 },
{ name: 'Ines', color: 'chart-3', parts: [[118, 141]], seed: 53 },
];
export default function TimelinePodcast() {
const [voices, setVoices] = useState(VOICES);
return (
<Timeline duration={200} defaultZoom={4.2} defaultTime={64}>
<Timeline.Header>
<Timeline.Time />
<Timeline.Zoom />
</Timeline.Header>
<Timeline.Viewport>
<Timeline.Ruler />
{voices.map((voice, v) => (
<Timeline.Track key={voice.name} size="md" label={voice.name}>
{voice.parts.map(([start, end], p) => (
<Timeline.Clip
key={p}
variant="media"
color={voice.color}
label={voice.name}
start={start}
end={end}
onChange={(span) =>
setVoices((all) =>
all.map((each, i) =>
i === v ? { ...each, parts: each.parts.map((part, j) => (j === p ? [span.start, span.end] : part)) } : each
)
)
}
>
<Timeline.Waveform peaks={speech(Math.round((end - start) * 1.6), voice.seed + p)} />
{p === 0 ? <Timeline.ClipLabel className="absolute start-1">{voice.name}</Timeline.ClipLabel> : null}
</Timeline.Clip>
))}
</Timeline.Track>
))}
<Timeline.Playhead color="destructive" />
</Timeline.Viewport>
</Timeline>
);
}Motion
A timeline is not only for minutes. Here it lays out a card's entrance across 1.6 seconds, one property per track, and the ruler counts in fractions of a second at this zoom. minZoom and maxZoom set how far it zooms.
'use client';
import { useState } from 'react';
import { Timeline, type TimelineColor } from '@/components/panelui/timeline';
/** Each property of a card's entrance, tweened over its own stretch of time. */
const PROPERTIES: { name: string; color: TimelineColor; start: number; end: number }[] = [
{ name: 'Opacity', color: 'chart-2', start: 0, end: 0.4 },
{ name: 'Lift', color: 'info', start: 0.1, end: 0.9 },
{ name: 'Scale', color: 'destructive', start: 0.25, end: 1.1 },
{ name: 'Blur', color: 'warning', start: 0, end: 0.6 },
];
export default function TimelineMotion() {
const [tweens, setTweens] = useState(PROPERTIES);
return (
<Timeline duration={1.6} defaultZoom={300} minZoom={150} maxZoom={600} defaultTime={0.5}>
<Timeline.Header>
<Timeline.Time />
<Timeline.Zoom />
</Timeline.Header>
<Timeline.Viewport>
<Timeline.Ruler />
{tweens.map((tween, at) => (
<Timeline.Track key={tween.name} size="sm" label={tween.name}>
<Timeline.Clip
variant="bar"
label={tween.name}
color={tween.color}
start={tween.start}
end={tween.end}
onChange={(span) => setTweens((all) => all.map((each, i) => (i === at ? { ...each, ...span } : each)))}
>
<Timeline.ClipLabel className="text-[11px]">{tween.name}</Timeline.ClipLabel>
</Timeline.Clip>
</Timeline.Track>
))}
<Timeline.Playhead />
</Timeline.Viewport>
</Timeline>
);
}Read only
readOnly shows the clips without letting them be moved or trimmed, for a published cut or a schedule. The playhead can still be moved, and the header can be left out.
'use client';
import { CaptionsIcon, MusicIcon } from 'lucide-react';
import { Timeline } from '@/components/panelui/timeline';
export default function TimelineReadOnly() {
return (
<Timeline duration={95} defaultZoom={7} defaultTime={31} readOnly>
<Timeline.Viewport>
<Timeline.Ruler />
<Timeline.Track size="sm" label="Captions">
{[
[2, 9],
[10, 18],
[21, 30],
[33, 41],
[44, 57],
[60, 66],
[70, 83],
[86, 93],
].map(([start, end]) => (
<Timeline.Clip key={start} color="chart-3" start={start!} end={end!} label="Caption" />
))}
</Timeline.Track>
<Timeline.Track label="Music">
<Timeline.Clip variant="media" color="chart-5" start={0} end={64} label="Opening theme">
<Timeline.ClipLabel>
<MusicIcon />
Opening theme
</Timeline.ClipLabel>
</Timeline.Clip>
<Timeline.Clip variant="media" color="chart-2" start={64} end={95} label="Outro">
<Timeline.ClipLabel>
<CaptionsIcon />
Outro
</Timeline.ClipLabel>
</Timeline.Clip>
</Timeline.Track>
<Timeline.Playhead />
</Timeline.Viewport>
</Timeline>
);
}API Reference
Timeline
| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | — | How long the timeline is, in seconds. |
time | number | — | Where the playhead is, in seconds, when the caller holds it. |
defaultTime | number | 0 | Where the playhead starts, when the caller does not hold it. Default 0. |
onTimeChange | (time: number) => void | — | Called with the new time when the playhead moves: scrubbed, stepped or playing. |
zoom | number | — | Pixels per second, when the caller holds it. |
defaultZoom | number | 12 | Pixels per second to start at. Default 12. |
onZoomChange | (zoom: number) => void | — | Called with the new zoom. |
minZoom | number | 2 | The furthest out it zooms, in pixels per second. Default 2. |
maxZoom | number | 120 | The furthest in it zooms, in pixels per second. Default 120. |
playing | boolean | — | Whether time is running, when the caller holds it. |
onPlayingChange | (playing: boolean) => void | — | Called when playback starts or stops, including when it reaches the end. |
loop | boolean | false | Start again from the beginning on reaching the end, instead of stopping. |
snap | boolean | true | Snap clip edges to other edges, the playhead and the ends. Default true. |
readOnly | boolean | false | Show it without letting clips be moved or trimmed. The playhead still moves. |
Timeline.Time
| Prop | Type | Default | Description |
|---|---|---|---|
showDuration | boolean | — | Show the length after the time, as 1:32 / 2:30. Default true. |
Timeline.Track
| Prop | Type | Default | Description |
|---|---|---|---|
size | TimelineTrackSize | — | The height of the row: sm for bars, md for blocks, lg for media. Default md. |
label | string | — | What the track holds, for a screen reader: "Zooms", "Voice". |
Timeline.Clip
| Prop | Type | Default | Description |
|---|---|---|---|
start | number | — | Where it starts, in seconds. |
end | number | — | Where it ends, in seconds. |
onChange | (span: { start: number; end: number }) => void | — | Called with the new span while it is moved or trimmed. Without it the clip stays put. |
onChangeEnd | (span: { start: number; end: number }) => void | — | Called once a move or a trim ends. |
onRemove | () => void | — | Called on Delete or Backspace while the clip has focus. |
variant | TimelineClipVariant | — | bar, block or media. Default: bar on a small track, media on a large one, block otherwise. |
color | TimelineColor | 'primary' | Its colour, from the theme. Default primary. |
label | string | — | What a screen reader calls it, before its times. |
Timeline.Waveform
| Prop | Type | Default | Description |
|---|---|---|---|
peaks | number[] | — | Loudness along the clip, each from 0 to 1. Drawn mirrored about the middle. |
Timeline.Playhead
| Prop | Type | Default | Description |
|---|---|---|---|
color | TimelineColor | 'primary' | The colour of the line and its head. Default primary. |
showTime | boolean | — | Write the time in the head. Default true. |
Every part also takes the props of the element it renders, and a className.
Notes
Snapping
While a clip is moved or trimmed, an edge within 8 pixels of another clip's edge, the playhead or either end of the timeline snaps to it, and a guide line marks where. snap={false} turns it off.
Keyboard
A clip takes focus. The left and right arrows move it by a tenth of a second, a second with Shift, and Delete calls onRemove. The playhead's head is a slider: the arrows step it, Home and End go to the ends, and Space plays and pauses.
Time and zoom
Times are in seconds. zoom is in pixels per second, from minZoom to maxZoom; at 7, a two-minute recording is about 840 pixels wide. time, zoom and playing can each be held by the caller, with onTimeChange, onZoomChange and onPlayingChange.
With reduced motion turned on, clips do not swell while they are dragged.
Server Components
Timeline is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Timeline.Header off the import, so each part is also exported by name: TimelineHeader is Timeline.Header, and so on for the others.
import { Timeline, TimelineHeader, TimelineTime, TimelineZoom, TimelineViewport, TimelineRuler, TimelineTrack, TimelineClip, TimelineClipLabel, TimelineWaveform, TimelinePlayhead } from "@/components/panelui/timeline"