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.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import 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-variants

Add 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. size sets its height: sm for bars, md for blocks, lg for media. label names it for a screen reader.
  • Timeline.Clip — One clip from start to end, in seconds. variant is bar, block or media, and follows the track's size unless set. color takes a theme colour. With onChange it can be moved and trimmed, and onRemove is 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, from peaks between 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

PropTypeDefaultDescription
durationnumber—How long the timeline is, in seconds.
timenumber—Where the playhead is, in seconds, when the caller holds it.
defaultTimenumber0Where 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.
zoomnumber—Pixels per second, when the caller holds it.
defaultZoomnumber12Pixels per second to start at. Default 12.
onZoomChange(zoom: number) => void—Called with the new zoom.
minZoomnumber2The furthest out it zooms, in pixels per second. Default 2.
maxZoomnumber120The furthest in it zooms, in pixels per second. Default 120.
playingboolean—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.
loopbooleanfalseStart again from the beginning on reaching the end, instead of stopping.
snapbooleantrueSnap clip edges to other edges, the playhead and the ends. Default true.
readOnlybooleanfalseShow it without letting clips be moved or trimmed. The playhead still moves.

Timeline.Time

PropTypeDefaultDescription
showDurationboolean—Show the length after the time, as 1:32 / 2:30. Default true.

Timeline.Track

PropTypeDefaultDescription
sizeTimelineTrackSize—The height of the row: sm for bars, md for blocks, lg for media. Default md.
labelstring—What the track holds, for a screen reader: "Zooms", "Voice".

Timeline.Clip

PropTypeDefaultDescription
startnumber—Where it starts, in seconds.
endnumber—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.
variantTimelineClipVariant—bar, block or media. Default: bar on a small track, media on a large one, block otherwise.
colorTimelineColor'primary'Its colour, from the theme. Default primary.
labelstring—What a screen reader calls it, before its times.

Timeline.Waveform

PropTypeDefaultDescription
peaksnumber[]—Loudness along the clip, each from 0 to 1. Drawn mirrored about the middle.

Timeline.Playhead

PropTypeDefaultDescription
colorTimelineColor'primary'The colour of the line and its head. Default primary.
showTimeboolean—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"

On this page