Timeline
Tracks, clips, a ruler and a playhead, with drag, trim, snapping and zoom built in.
<Timeline.*> is a set of parts you nest to build a timeline. The parts handle layout in time, scrolling, zoom, clip move and trim with snapping, ruler seeking and the playhead. You decide how each part looks.
Example
import { useState } from "react";
import { Timeline, useTracks } from "@miraiclip/react";
const HEADER_WIDTH = 120;
const RULER_HEIGHT = 28;
const TRACK_HEIGHT = 48;
// Inside <ProjectProvider> and <PlaybackProvider>.
export function EditorTimeline() {
const [pxPerSecond, setPxPerSecond] = useState(60);
// useTracks() is in render order (bottom layer first); list the top layer first, like most editors.
const tracks = useTracks().slice().reverse();
return (
<Timeline.Root
pxPerSecond={pxPerSecond}
onPxPerSecondChange={setPxPerSecond} // Ctrl/⌘ + wheel zooms around the pointer
headerWidth={HEADER_WIDTH}
className="relative flex h-64 flex-col bg-neutral-950 text-xs text-neutral-300"
>
<Timeline.Viewport className="min-h-0 flex-1">
{/* Ruler row: sticks to the top; its first cell sticks to the left too. */}
<div className="sticky top-0 z-30 flex" style={{ height: RULER_HEIGHT }}>
<div
className="sticky left-0 z-20 shrink-0 border-r border-b border-neutral-800 bg-neutral-950"
style={{ width: HEADER_WIDTH }}
/>
<Timeline.Ruler
className="flex-1 cursor-pointer border-b border-neutral-800 bg-neutral-950"
renderTick={(tick) => (
<span
className="absolute top-0 flex h-full items-center border-l border-neutral-800 pl-1 text-[10px] tabular-nums text-neutral-500"
style={{ left: tick.x }}
>
{tick.label}
</span>
)}
>
<Timeline.Playhead className="z-10 h-full w-px bg-sky-400" />
</Timeline.Ruler>
</div>
{tracks.map((track) => (
<Timeline.Track key={track.id} track={track} style={{ height: TRACK_HEIGHT }}>
<Timeline.TrackHeader className="flex items-center border-r border-b border-neutral-800 bg-neutral-950 px-3">
{track.name}
</Timeline.TrackHeader>
<Timeline.Lane className="border-b border-neutral-900 data-[drop-target]:bg-sky-400/10">
{(clip) => (
<Timeline.Clip
clip={clip}
insetPx={2}
className="group top-1.5 bottom-1.5 flex cursor-grab items-center overflow-hidden rounded-md border border-sky-400/40 bg-sky-400/15 px-2 text-sky-100 data-[dragging]:z-10 data-[dragging]:cursor-grabbing data-[dragging]:shadow-lg data-[locked]:opacity-50 data-[selected]:ring-2 data-[selected]:ring-sky-400"
>
<span className="pointer-events-none truncate">{"text" in clip ? clip.text : clip.kind}</span>
<Timeline.TrimHandle side="start" className="w-1.5 bg-sky-400 opacity-0 group-data-[selected]:opacity-100" />
<Timeline.TrimHandle side="end" className="w-1.5 bg-sky-400 opacity-0 group-data-[selected]:opacity-100" />
</Timeline.Clip>
)}
</Timeline.Lane>
</Timeline.Track>
))}
{/* The playhead line over the lanes: a layer that starts where the lanes start. */}
<div
className="pointer-events-none absolute z-10"
style={{ left: HEADER_WIDTH, top: RULER_HEIGHT, height: tracks.length * TRACK_HEIGHT }}
>
<Timeline.Playhead className="h-full w-px bg-sky-400" />
</div>
</Timeline.Viewport>
</Timeline.Root>
);
}Anatomy
<Timeline.Root pxPerSecond={…}> // zoom, drag state, context
<Timeline.PlayheadFollow /> // optional: keeps the playhead in view
<Timeline.Viewport> // the scroll container, both axes
<Timeline.Ruler> // ticks; press or drag to seek
<Timeline.Playhead />
</Timeline.Ruler>
<Timeline.Track track={track}> // one row
<Timeline.TrackHeader /> // sticks to the left edge
<Timeline.Lane overlay={<Timeline.Cuts>…</Timeline.Cuts>}>
{(clip) => (
<Timeline.Clip clip={clip}>
<Timeline.TrimHandle side="start" />
<Timeline.TrimHandle side="end" />
</Timeline.Clip>
)}
</Timeline.Lane>
</Timeline.Track>
</Timeline.Viewport>
</Timeline.Root>Every part needs ProjectProvider and PlaybackProvider above it. Each part renders one <div> (except Cuts and PlayheadFollow, which render only what you return) and passes other props through to it.
Parts
Root
Holds the zoom and the clip being dragged, and gives every part below it the timeline's scale.
| Prop | Default | Description |
|---|---|---|
pxPerSecond | required | Zoom: horizontal pixels per second |
onPxPerSecondChange | Called by Ctrl/⌘ + wheel zoom, which keeps the time under the pointer fixed. Leave it out to turn wheel zoom off | |
minPxPerSecond, maxPxPerSecond | 4, 400 | Wheel zoom limits |
headerWidth | 0 | Width of the sticky header column inside the viewport, px |
lengthUs | composition + 5 s, at least 10 s | How far the timeline scrolls |
snapPx | 8 | Clip edges snap to other clips, 0 and the playhead within this many pixels. 0 turns snapping off |
data-dragging is "move", "trim-start" or "trim-end" while a clip is dragged.
Viewport
The scroll container for both axes. Its content is headerWidth plus the timeline's length wide. Give it a height (or flex-1 in a column) so it scrolls vertically. contentProps go to the inner element.
Ruler
Draws ticks for the visible range only. A press or drag on it seeks.
| Prop | Default | Description |
|---|---|---|
stepUs | the smallest step that keeps ticks 60 px apart | Tick spacing: a fixed value, or (pxPerSecond) => us |
renderTick | a <span data-tick> with the label | (tick) => node, where tick is { us, x, label }. Place it at left: tick.x |
children | Drawn after the ticks, for example a <Timeline.Playhead> knob |
rulerStepUs(pxPerSecond, minGapPx?) is the default step, if you want to reuse it.
Track and TrackHeader
<Timeline.Track track={track}> is one row (display: flex). It sets data-kind, data-locked, data-muted and data-hidden from the track. <Timeline.TrackHeader> is headerWidth wide and sticks to the left edge while the lanes scroll; put names and toggles in it.
Lane
Renders the track's clips through its children function. Only clips that overlap the visible range (plus a margin) are rendered, plus the clip being dragged, so a lane costs what is on screen whatever the project's length.
| Prop | Default | Description |
|---|---|---|
children | required | (clip) => node, usually a <Timeline.Clip> |
overlay | Drawn after the clips: cut markers, drop indicators | |
deselectOnClick | true | A click on the empty lane clears the selection |
data-drop-target is set while a clip from another track is dragged over the lane.
Clip
Places a clip at its time and makes it draggable. A press selects it and starts a move; release commits one clip/move, which is one undo step. A clip can move to another track that accepts its kind. On touch, the first tap selects and a swipe scrolls the timeline; once selected, a drag moves the clip.
| Prop | Default | Description |
|---|---|---|
clip | required | The clip to draw |
insetPx | 0 | Pixels taken off the left edge, for a gap between neighbouring clips |
minWidthPx | 2 | Narrowest drawn width |
children | Nodes, or (state) => node with the state below |
| Data attribute | Set when |
|---|---|
data-selected | The clip is selected |
data-dragging | "move", "trim-start" or "trim-end" while this clip is dragged |
data-locked | Its track is locked: the clip can be selected but not dragged |
data-kind | Always: the clip kind (video, audio, text, …) |
The clip's left and width come from its live position, so during a drag it moves under the pointer before anything is committed. Set its vertical position yourself (top/bottom or height).
useTimelineClip() returns the same state inside a clip:
| Field | Description |
|---|---|
clip | The clip |
startUs, durationUs | Live position: the drag preview while dragged |
trimStartUs | Media in-point, live while trimming the start (0 for clips without media) |
selected, locked | As the data attributes |
dragging | The drag mode, or null |
TrimHandle
A handle on the start or end edge of the enclosing clip. Dragging it trims the clip, keeping at least 200 ms, and moves the media in-point for video and audio. It is absolutely placed at full height on its edge; give it a width. data-active is set while it is dragged.
Playhead
The playhead, moved on the compositor: no React render or style recalculation per frame. It sits at its parent's left edge, so put it in the ruler, or in an absolutely placed layer that starts where the lanes start (as in the example). Style the line and add a knob as children.
Cuts
Renders a node for each cut on the track: a place where one visual clip ends exactly where the next starts, which is where a transition can go. Use it as a lane's overlay. Only cuts in view are passed, and markers are hidden while a clip is dragged (hideWhileDragging, default true).
<Timeline.Lane
overlay={
<Timeline.Cuts>
{({ x, from, to, transition }) => (
<button
type="button"
className="absolute top-1 h-4 w-4 -translate-x-1/2 rounded-full bg-white text-[10px]"
style={{ left: x }}
onClick={() => openTransitionPicker(from.id, to.id)}
>
{transition ? "◆" : "+"}
</button>
)}
</Timeline.Cuts>
}
>
{(clip) => <Timeline.Clip clip={clip} />}
</Timeline.Lane>x is the cut's offset from the lane's left edge; atUs, from, to and transition come from trackCuts in @miraiclip/editor.
PlayheadFollow
While playing, pages the timeline so the playhead stays in view, pausing for 1.5 s after the user scrolls by hand. Its children function receives where the playhead is when it is off screen, for a "jump to playhead" button. It re-renders on its own, never the timeline.
<Timeline.PlayheadFollow>
{({ side, us, reveal }) =>
side && (
<button type="button" onClick={reveal} className="absolute top-1 z-40 rounded-full bg-sky-500 px-2 text-xs text-white" style={side === "left" ? { left: 128 } : { right: 8 }}>
{formatClock(us)}
</button>
)
}
</Timeline.PlayheadFollow>Hooks
| Hook | Returns | Use inside |
|---|---|---|
useTimeline() | pxPerSecond, headerWidth, lengthUs, viewportRef, usAtClientX(x), beginClipDrag(event, clip, mode) | Root |
useTimelineRange() | { fromUs, toUs } in view, with a margin | Root; for virtualising your own rows |
useTimelineDrag() | The dragged clip as it would land now (ClipDragPreview), or null | Root |
useTimelineTrack() | The enclosing track | Track |
useTimelineClip() | The clip state above | Clip |
beginClipDrag is what Clip and TrimHandle call, so a custom handle can start a move or trim from its own onPointerDown.
Customising
| To | Do |
|---|---|
| Use a different clip look per kind | Branch on clip.kind in the lane's children, or style [data-kind="audio"] |
| Draw audio | Put a <Waveform> inside the clip |
| Keep zoom in your own store | pxPerSecond and onPxPerSecondChange are controlled props; wire them to a zoom slider too |
| Turn snapping off | snapPx={0} |
| Add keyboard shortcuts | useEditorShortcuts(["delete", "split"]) from the React package |
| Build the timeline without these parts | Use the hooks (useVisibleRange, useCompositorPlayhead, useTimelineZoom) and beginClipDrag, as in Build an editor › Timeline |