Skip to content
Entangle UI v0.13.0

Timeline

A horizontal, multi-track animation timeline / dope sheet for editor UIs. Each track is an animated property whose keyframes (the shared CurveKeyframe model, reused from CurveEditor) lie along a frame-based time axis. A playhead scrubs across all tracks; the view zooms and pans over time. Keyframes render on a perf-isolated canvas while chrome stays in the DOM.

Timeline is the flagship editor component: it ships dope-sheet and graph (value-curve) modes, collapsible track groups, per-track expand-to-graph, header reorder, a draggable loop region, copy / paste, vertical scrolling, an imperative handle, hooks for live state, and an accessibility baseline — all in one component.

Live Preview

For a full, self-contained editor built around Timeline (3D-CSS cube driven live by the keyframe curves, scene tree, frame-bound inspector), see the Animation Editor showcase →

import {
Timeline,
framesToTimecode,
useTimelineContext,
useTimelineGeometry,
useTimelinePlayhead,
useTimelineSelection,
} from 'entangle-ui';
import type {
TimelineProps,
TimelineTrack,
TimelineGroup,
TimelineKeyframeRef,
TimelineSelection,
TimelineView,
TimelineMode,
TimelineLoop,
TimelineInfinity,
TimelineHandle,
TimelineDrawInfo,
TimelineTrackHeaderInfo,
} from 'entangle-ui';

tracks is controlled — hold the data yourself and update it from onTracksChange (continuous, during a drag) and/or onTracksChangeComplete (commit, for an undo stack). The playhead frame and the visible view are each independently controlled or uncontrolled.

Minimal setup

import { useState } from 'react';
import { Timeline, type TimelineTrack } from 'entangle-ui';
const kf = (id: string, x: number, y: number) => ({
id,
x, // frame
y, // value
handleIn: { x: 0, y: 0 },
handleOut: { x: 0, y: 0 },
tangentMode: 'auto' as const,
});
function Editor() {
const [tracks, setTracks] = useState<TimelineTrack[]>([
{
id: 'opacity',
label: 'Opacity',
keyframes: [kf('a', 0, 1), kf('b', 48, 0)],
},
]);
const [frame, setFrame] = useState(0);
return (
<div style={{ height: 320 }}>
<Timeline
tracks={tracks}
onTracksChange={setTracks}
endFrame={72}
fps={24}
frame={frame}
onFrameChange={setFrame}
/>
</div>
);
}

The timeline fills its parent when responsive (the default), so give the wrapper a height — or pass a fixed height={number}.

A TimelineTrack is a thin wrapper around a list of CurveKeyframes — the same model used by CurveEditor, so timelines and curve editors speak the same language and a curve panel can pop out of a row without converting data.

interface TimelineTrack {
id: string;
label?: string;
color?: string;
keyframes: CurveKeyframe[]; // shared with CurveEditor
valueRange?: [number, number]; // graph-mode domain; auto-fit if omitted
infinity?: { pre?: InfinityMode; post?: InfinityMode };
locked?: boolean; // ignore edits + dim header
hidden?: boolean; // exclude from layout entirely
expanded?: boolean; // open as a graph lane in dope mode
groupId?: string; // join a TimelineGroup
height?: number; // row-height override
}
interface CurveKeyframe {
id?: string;
x: number; // frame
y: number; // value
handleIn: { x: number; y: number };
handleOut: { x: number; y: number };
tangentMode: 'auto' | 'linear' | 'step' | 'aligned' | 'mirrored' | 'free';
}

Pure helpers consumed by the timeline:

  • framesToTimecode(frame, fps) — the built-in HH:MM:SS:FF formatter.
  • evaluateCurve(curve, frame) (re-exported by CurveEditor) — sample a track’s value at any frame for live-driven scenes; the Animation Editor showcase uses this to drive a CSS-3D cube straight from the timeline tracks.

Time is expressed in frames with an fps prop that drives the HH:MM:SS:FF ruler timecode and the snap grid. The global range is startFrame..endFrame; the visible window is the view ({ startFrame, endFrame }), bounded by minFramesVisible / maxFramesVisible. Pass formatTime={(frame, fps) => string} to override ruler labels — e.g. show plain seconds instead of timecode.

Custom ruler format

Toggle mode between 'dope-sheet' (keyframe timing, the default) and 'graph' (value curves). In graph mode every track draws its animation curve — reusing CurveEditor’s evaluateCurve — with keyframe points you can drag in time and value, plus draggable bezier tangent handles on the selected keyframe (honoring its tangentMode).

Dope sheet ↔ graph

mode is controlled (mode / defaultMode / onModeChange).

In dope-sheet mode an individual track can open its value curve in place — set expanded: true on the track (or use the disclosure caret in its header). Expanded tracks render a taller graph lane (expandedTrackHeight, default 96) with the same draggable points and tangent handles as global graph mode, while the rest stay compact. This lets you tweak one channel’s curve without leaving the dope sheet.

One track, opened in place

Organize related tracks into collapsible folders. A track joins a group via groupId; the groups prop lists the groups in order:

Transform / Material / Light

<Timeline
tracks={tracks} // each with groupId: 'transform' | 'material' | 'light'
groups={[
{ id: 'transform', label: 'Transform' },
{ id: 'material', label: 'Material' },
{ id: 'light', label: 'Light', collapsed: true },
]}
onGroupsChange={setGroups}
/>

Each group renders a header row in both the header column and the canvas; clicking the header (or its caret) toggles collapsed, hiding the group’s tracks. Groups are controlled or uncontrolled (groups / onGroupsChange). Grouped track headers are indented under their group; tune the header height with groupHeaderHeight (default 22). The current model is flat — one groupId per track, no nested folders.

Timeline ships an optional built-in clock: set playing (or call ref.play() / ref.toggle()) and it advances frame in real time at fps, firing onFrameChange each tick. loop (true, or a { startFrame, endFrame } sub-range) repeats; otherwise playback stops at the end. frame stays fully controllable, so an external clock — audio playback, an engine tick, a server-driven sequence — can drive it instead of the built-in loop.

Play / loop region drag

When loop is set its region is highlighted on the ruler and shaded across the track area. Editing it (all controlled via loop / defaultLoop / onLoopChange):

  • Alt + drag on the ruler creates or re-draws the loop — even when looping was off, the drag turns it on. A bare Alt-click clears it.
  • Drag a loop edge to resize; the edge brightens + the cursor becomes ew-resize on hover so it reads as grabbable.
  • Drag the loop body to move the whole region (cursor grab).

Wire onLoopChange to persist these drags — without it the loop is fully controlled and any drag snaps back to the prop value.

Two opt-in props tune the loop chrome:

  • loopStrip — adds a thin dedicated band directly under the ruler. Plain drag on the strip creates the loop (no Alt needed); the ruler keeps its scrub behaviour. Useful when Alt-as-modifier feels too hidden.
  • loopHandles'edges' (default) draws full-height vertical bars on each loop edge; 'brackets' draws compact [ ] markers with serifs in the chrome area instead, with a wider pick zone for easier grabbing.
<Timeline
tracks={tracks}
loop={loop}
onLoopChange={setLoop}
loopStrip
loopHandles="brackets"
/>

Selection is a TimelineSelection ({ trackId, keyframeId }[]) — controlled or uncontrolled. Click a keyframe to select; Shift / Ctrl / Cmd toggles into the selection; drag on empty space to box-select across tracks.

Live selection readout

Drag a selected keyframe to move it (snapped, clamped to range); double-click empty space on a track to add a keyframe there; Delete / Backspace removes the selection; Ctrl / Cmd + C / V copies the selection and pastes it at the current playhead frame, preserving relative offsets. In an expanded lane (or in graph mode) the bezier tangent handles on the selected keyframe are draggable too — handle dragging respects the keyframe’s tangentMode (auto/linear/step are promoted to aligned on first drag; mirrored keeps both handles equal-and-opposite).

Full edit playground

For an undo stack: subscribe to onTracksChangeComplete (fired on pointer-up, not on every drag tick) and push that snapshot — drag intermediates go through onTracksChange only.

When tracks overflow the available height the track area scrolls vertically: a plain mouse wheel scrolls the rows (the time ruler stays pinned), while Ctrl/Cmd + wheel zooms the time axis instead. Off-screen rows are skipped in draw + hit-test, and row layout (group headers, expanded lanes, variable height overrides) is computed once and shared by the canvas, hit-testing, and the header column so everything stays aligned.

24 tracks, scrollable

renderTrackHeader(track, info) replaces the built-in label + swatch with your own DOM — use it for mute / solo, per-track icons, level meters, or any inline control. The function receives the rendered track index and a hasSelection flag for highlight styling.

Mute / solo headers

<Timeline
tracks={tracks}
renderTrackHeader={(track, info) => (
<ChannelHeaderRow
track={track}
muted={meta[track.id]?.muted}
solo={meta[track.id]?.solo}
selected={info.hasSelection}
/>
)}
/>

When renderTrackHeader is provided, header-drag reorder is disabled (custom headers own their own pointer interactions). Wire your own reorder inside the custom row if you need it, and call onTracksChange with the new order.

Drop arbitrary children into Timeline.Toolbar and Timeline.Footer and they’re slotted into the matching chrome strips. Use the slots to host transport buttons, mode toggles, current-frame timecode, statistics, or anything else that belongs inside the timeline frame instead of around it.

Custom transport + footer readout

<Timeline tracks={tracks} endFrame={90} fps={30}>
<Timeline.Toolbar>
<Button onClick={togglePlay}>{playing ? 'Pause' : 'Play'}</Button>
</Timeline.Toolbar>
<Timeline.Footer>
<span>{framesToTimecode(frame, 30)}</span>
</Timeline.Footer>
</Timeline>

renderOverlay runs after the built-in track content (and before the playhead), receiving the 2D context plus the same frameToX / xToFrame math the component uses internally — draw act breaks, region highlights, markers, or any annotations aligned to the time axis.

Region highlight + label

<Timeline
tracks={tracks}
endFrame={72}
renderOverlay={(ctx, info) => {
const x0 = info.frameToX(30);
const x1 = info.frameToX(50);
ctx.fillStyle = 'rgba(224, 166, 75, 0.10)';
ctx.fillRect(x0, 0, x1 - x0, info.size.height);
}}
/>

Pass a ref to drive the timeline programmatically — useful for binding external transport buttons, syncing from a parent player, or running scripted demos.

External transport via ref

const ref = useRef<TimelineHandle>(null);
ref.current?.seek(48);
ref.current?.play();
ref.current?.toggle();
ref.current?.zoomToFit(8);
ref.current?.zoomToSelection();
const x = ref.current?.frameToX(48); // frame → track-area pixel

seek · play / pause / toggle · getFrame · zoomToFit(paddingFrames?) · zoomToSelection · getView · frameToX / xToFrame · focus / getElement.

Children rendered inside <Timeline> can subscribe to the live store via hooks — useTimelinePlayhead() (current frame + playing flag), useTimelineSelection() (selection list), useTimelineGeometry() (view / size / track-height / scroll), and useTimelineContext() (the raw store). Each hook uses useSyncExternalStore so children only re-render on the slice they read.

Overlay reading the store

function PlayheadBadge() {
const { frame, playing } = useTimelinePlayhead();
return (
<span>
Frame {Math.round(frame)} · {playing ? 'playing' : 'idle'}
</span>
);
}
<Timeline tracks={tracks} endFrame={72}>
<PlayheadBadge />
</Timeline>;

Each graph-mode track can extrapolate beyond its first / last keyframe via infinity: { pre, post } — the modes ('constant', 'linear', 'cycle', 'oscillate') are reused from CurveEditor. Pair with valueRange to fix the Y domain so the curve doesn’t auto-fit as you edit.

Cycle + oscillate

Pass trackScale to draw a value axis on each graph / expanded lane — a thin axis line with min / max tick labels, an optional midpoint, and optional gridlines across the lane:

<Timeline
tracks={tracks}
trackScale={{
position: 'start', // or 'end'
showMidpoint: true,
gridlines: true,
minLaneHeight: 48, // hide the scale on lanes shorter than this
format: v => `${Math.round(v)}px`,
}}
/>

minLaneHeight (default 48) is the important one in graph mode: a collapsed track lane is too short to fit three labels, so the scale is skipped below that height instead of collapsing into an unreadable blob. The deprecated <Timeline.TrackScale /> slot accepts the same props.

| Gesture | Action | | ----------------------------------------- | ------------------------------------------------------------------------ | | Drag / click the ruler | Scrub the playhead (snaps to frame) | | Grab the playhead line | Scrub — grabbable along its whole height (ruler, group rows, tracks) | | Click a keyframe | Select it | | Shift / Ctrl + click | Add / toggle in the selection | | Drag on empty space | Box-select across tracks | | Drag a selected keyframe | Move keyframes (snapped, clamped to range) | | Drag a tangent handle (selected keyframe) | Edit the curve shape (graph / expanded lanes) | | Alt + drag on the ruler | Create / re-draw the loop region (Alt-click clears it) | | Drag on the loop strip (loopStrip) | Create / re-draw the loop — no Alt needed | | Drag the loop edges or body | Resize / move the loop region | | Double-click empty space on a row | Add a keyframe on that track | | Delete / Backspace | Remove the selected keyframes | | Ctrl/Cmd + C / V | Copy / paste the selected keyframes at the playhead | | Wheel | Scroll tracks vertically when they overflow, else zoom | | Ctrl/Cmd + wheel | Zoom the time axis around the cursor | | Shift + wheel / middle-drag | Pan the time axis | | ← / → (Shift = ×10) | Step the playhead; Home / End jump to the range ends | | Drag a track header | Reorder tracks (built-in header only) | | Click a group header (or caret) | Collapse / expand the group | | Click a track’s expand caret | Open / close that track as a graph lane |

Hold Ctrl while dragging to momentarily disable snap-to-frame.

const [tracks, setTracks] = useState(initialTracks);
const undoStack = useRef<TimelineTrack[][]>([]);
<Timeline
tracks={tracks}
onTracksChange={setTracks} // live: every drag tick
onTracksChangeComplete={next => {
undoStack.current.push(tracks); // snapshot the *previous* state
setTracks(next);
}}
/>;
// Built-in playback off; advance the frame from your audio engine instead.
const audioRef = useRef<HTMLAudioElement>(null);
useEffect(() => {
const el = audioRef.current;
if (!el) return;
const tick = () => setFrame(el.currentTime * fps);
el.addEventListener('timeupdate', tick);
return () => el.removeEventListener('timeupdate', tick);
}, [fps]);
<Timeline frame={frame} onFrameChange={f => seekAudio(f / fps)} />;
<Timeline
tracks={tracks}
editable={false} // disables drag, add, delete, copy/paste
showPlayhead
/>
const tracks = [
{ id: 'reference', label: 'Reference (locked)', locked: true, keyframes },
{ id: 'live', label: 'Live edit', keyframes },
];

Insert a keyframe programmatically at the playhead

Section titled “Insert a keyframe programmatically at the playhead”
function insertKey(trackId: string) {
setTracks(prev =>
prev.map(t =>
t.id !== trackId
? t
: {
...t,
keyframes: [
...t.keyframes,
{
id: crypto.randomUUID(),
x: frame,
y: 0,
handleIn: { x: 0, y: 0 },
handleOut: { x: 0, y: 0 },
tangentMode: 'auto',
},
].sort((a, b) => a.x - b.x),
}
)
);
}
<Timeline
selection={selection}
onSelectionChange={next =>
setSelection(next.filter(r => r.trackId === activeTrackId))
}
/>

Frame-step from a keyboard binding outside the timeline

Section titled “Frame-step from a keyboard binding outside the timeline”
const ref = useRef<TimelineHandle>(null);
useHotkey('Right', () => ref.current?.seek(ref.current.getFrame() + 1));
useHotkey('Shift+Right', () => ref.current?.seek(ref.current.getFrame() + 10));

The body is a focusable role="group" with aria-roledescription="Timeline" and your ariaLabel. Every playhead and editing action has a keyboard equivalent (see Interactions), and a polite live region announces the playhead frame on discrete moves (keyboard steps, clicks) while staying quiet during continuous scrubs and playback so AT isn’t flooded. The canvas is decorative — the keyboard model and live region carry the semantics. Group header rows and the per-track expand toggle expose aria-expanded, and the track header buttons advertise reorder affordance with data-draggable.

  • Always give the wrapper a height. Timeline is responsive by default and fills its parent; if the parent has no height, the canvas will collapse to 0px. Either set height={number} or wrap in a flex / fixed-height container.
  • endFrame is required. It bounds the global timeline range and the default view. There’s no auto-fit.
  • Controlled / uncontrolled is per-axis. frame, view, selection, mode, playing, loop, groups, and tracks are each independently controllable; mixing controlled frame with uncontrolled view is fine.
  • Keep tracks referentially stable across renders. Construct it outside the component (or memoize) so the canvas only redraws when something actually changed — Timeline already compares carefully, but a fresh array of fresh keyframes every render burns frames.
  • Selection identity uses trackId + keyframeId. Always give each keyframe a stable id so selection / copy-paste survive edits.
  • Custom headers disable reorder drag. Add your own pointer handlers inside the custom header if you want reorder behavior to come back.
  • onTracksChange fires on every drag tick. Use it for live preview; push to an undo stack from onTracksChangeComplete (pointer-up only).

The full props / type reference is auto-generated from TypeScript and lives under API → Timeline and TimelineProps. Notable types: TimelineTrack, TimelineGroup, TimelineHandle, TimelineSelection, TimelineView, TimelineDrawInfo, and TimelineTrackHeaderInfo.