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
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';Basic usage
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}.
Data model
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-inHH:MM:SS:FFformatter.evaluateCurve(curve, frame)(re-exported byCurveEditor) — 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 domain
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
Modes — dope sheet & graph
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).
Per-track expand-to-graph
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
Track groups
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.
Playback & loop region
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-resizeon 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 & box-select
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
Keyframe editing
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.
Vertical scrolling
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
Custom track headers
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.
Toolbar & footer slots
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>Custom canvas overlay
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); }}/>Imperative handle
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 pixelseek · play / pause / toggle · getFrame ·
zoomToFit(paddingFrames?) · zoomToSelection · getView · frameToX /
xToFrame · focus / getElement.
Live state hooks
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>;Pre / post infinity & value range
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
Track value scale
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.
Interactions
| 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.
Recipes
Push to an undo stack
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); }}/>;Drive the playhead from an external clock
// 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)} />;Read-only / locked playback
<Timeline tracks={tracks} editable={false} // disables drag, add, delete, copy/paste showPlayhead/>Lock a single track
const tracks = [ { id: 'reference', label: 'Reference (locked)', locked: true, keyframes }, { id: 'live', label: 'Live edit', keyframes },];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), } ) );}Filter the selection to one track
<Timeline selection={selection} onSelectionChange={next => setSelection(next.filter(r => r.trackId === activeTrackId)) }/>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));Accessibility
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.
Tips & gotchas
- Always give the wrapper a height. Timeline is
responsiveby default and fills its parent; if the parent has no height, the canvas will collapse to 0px. Either setheight={number}or wrap in a flex / fixed-height container. endFrameis required. It bounds the global timeline range and the defaultview. There’s no auto-fit.- Controlled / uncontrolled is per-axis.
frame,view,selection,mode,playing,loop,groups, andtracksare each independently controllable; mixing controlledframewith uncontrolledviewis fine. - Keep
tracksreferentially 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 stableidso 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.
onTracksChangefires on every drag tick. Use it for live preview; push to an undo stack fromonTracksChangeComplete(pointer-up only).
Reference
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.