Timeline
The Timeline component is a fully interactive NLE timeline. Tracks, clips, drag-to-trim, drag-to-move, snapping, zoom, and the full keyboard shortcut set.
Overview
The Timeline component reads state from useTracksStore and dispatches mutations through TimelineEngine. It is fully controlled by the Zustand stores — you can read or write those stores directly to drive the timeline from outside.
import { useRef } from 'react'
import {
EditorProvider,
Timeline,
type TimelineRef,
type InitialTrackConfig,
} from '@elah/editor'
const TRACKS: InitialTrackConfig[] = [
{ kind: 'video', name: 'Video / Image' },
{ kind: 'audio', name: 'Audio' },
{ kind: 'elements', name: 'Elements' },
]
export default function TimelineOnlyDemo() {
const ref = useRef<TimelineRef>(null)
return (
<EditorProvider fps={30} initialTracks={TRACKS}>
<Timeline
ref={ref}
fps={30}
style={{ height: 260 }}
/>
</EditorProvider>
)
}Tracks & Clips
Each Track has a kind: video, audio, or elements (text, shapes, and freehand live on elements tracks). A project can have any number of tracks of any kind, including several video tracks; initialTracks on EditorProvider sets the starting layout. Clips are stored on the Project keyed by track id (project.clips[trackId]).
interface Track {
id: string
name: string
kind: 'video' | 'audio' | 'elements'
order: number // lower = closer to top of timeline
height: number // px
locked: boolean
disabled: boolean
muted: boolean
solo: boolean
volume?: number // 0..2, linear
protected?: boolean // the user cannot remove this track
pinned?: 'bottom' // addTrack keeps this lane below every non-pinned track
}
interface Clip {
id: string
trackId: string
type: 'video' | 'audio' | 'text' | 'image' | 'shape' | 'freehand'
name: string
src?: string // URL or blob ref for video/audio/image
startFrame: number // integer — position on the timeline
durationFrames: number // integer — length on the timeline
sourceStartFrame: number // trim in-point into the source asset
sourceDurationFrames: number // source length (used for trim constraints)
transform?: Transform // position, scale, rotation (optional)
opacity?: number // 0..1, managed by the transition system
speed?: number // video only; 0.25..4, default 1
crop?: { x: number; y: number; width: number; height: number } // 0..1 of the source
cornerRadius?: number // video/image; 0..0.5 of the shorter side
// Text clips carry flat style fields (content, fontSize, color, ...).
}
// Clips are NOT nested on Track. The Project stores them keyed by track:
// project.clips: Record<string /* trackId */, Clip[]>Add and remove clips via the engine:
const engine = useTimelineEngine()
// Add a video clip
engine.addClip({
trackId: videoTrack.id,
type: 'video',
src: 'https://example.com/video.mp4',
startFrame: 0,
durationFrames: 150, // 5 seconds at 30fps
name: 'Intro',
})
// Move a clip to a new start frame (same track here — pass the target
// track id as the 3rd arg to move it across tracks).
engine.moveClip(clipId, videoTrack.id, videoTrack.id, 30)
// Remove a clip
engine.removeClip(clipId, trackId)
// Split the selected clip at the current playhead. Reads the selection
// and playhead from the stores — just hand it the engine.
import { splitClipAtPlayhead } from '@elah/editor'
splitClipAtPlayhead(engine)Playback
The PlaybackEngine owns the RAF clock and publishes (frame, isPlaying) snapshots. React reads playback state via usePlaybackStore:
import {
usePlaybackStore,
useTracksStore,
framesToTimecode,
} from '@elah/editor'
export function TransportControls({ fps = 30 }) {
const isPlaying = usePlaybackStore((s) => s.isPlaying)
const togglePlayPause = usePlaybackStore((s) => s.togglePlayPause)
const currentFrame = usePlaybackStore((s) => s.currentFrame)
const setCurrentFrame = usePlaybackStore((s) => s.setCurrentFrame)
const totalFrames = useTracksStore((s) => s.totalFrames)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<button onClick={() => setCurrentFrame(0)}>⏮</button>
<button onClick={togglePlayPause}>
{isPlaying ? '⏸ Pause' : '▶ Play'}
</button>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>
{framesToTimecode(currentFrame, fps)}
{' / '}
{framesToTimecode(totalFrames, fps)}
</span>
</div>
)
}Zooming & Snapping
The timeline supports Ctrl/Cmd + scroll to zoom, anchored on the pointer, and pinch to zoom on touch, anchored on the finger midpoint. Clips snap to other clip edges, the playhead, and track boundaries. The snap tolerance is configurable:
// Snapping is on by default. Ctrl/Cmd + scroll zooms the timeline.
<Timeline ref={ref} fps={30} style={{ height: 240 }} />
// The snap utilities are exported for custom drag implementations.
// buildSnapPoints takes the project's clips record (project.clips),
// snapFrame snaps a frame to the nearest point within a pixel threshold.
import {
snapFrame,
buildSnapPoints,
DEFAULT_OVERLAP_TOLERANCE,
} from '@elah/editor'
const snapPoints = buildSnapPoints(project.clips, excludeClipId)
const snappedFrame = snapFrame(frame, snapPoints, threshold)For toolbar buttons and sliders, use the imperative handle rather than writing the zoom store directly. zoomAtAnchor(nextZoom) sets the zoom in pixels per frame, clamped by the store, and keeps the view anchored: the playhead stays where it is on screen when it is visible, and the viewport centre does otherwise, so zooming never scrolls the playhead out of view. fitToWindow() zooms so the whole timeline fits the visible track area.
const ref = useRef<TimelineRef>(null)
<Timeline ref={ref} fps={30} />
// TimelineRef: { engine, playback, fitToWindow, zoomAtAnchor }
ref.current?.zoomAtAnchor(8) // 8 px per frame, anchored on the playhead
ref.current?.fitToWindow()Multiple Video Tracks
As of 0.6.0 a project can have more than one video track. engine.addTrack('video') adds another lane instead of returning the existing one. Each lane is its own row in the timeline, and overlapping clips on separate video lanes layer instead of conflicting. Lanes composite in track order, so the topmost lane draws on top, and a new video track is placed directly below the last one. See Clips & Tracks for placement, reordering, and the protected and pinned track options.
Clip Virtualization
On a long timeline, mounting every clip would dominate the render cost. The timeline mounts only the clips inside the scrolled viewport, plus a margin so clips do not visibly pop in during a fast scroll or drag. Nothing needs to be enabled and there is no prop for it.
- The visible window is quantized to 200 pixel steps, so rows re-render once per step of scrolling rather than on every pixel.
- The window is rounded outward (start down, end up), so it is always a superset of the true viewport and never culls a clip that is actually visible.
- The window is measured from the lane area only. The sticky track-label sidebar covers the left part of the viewport, so it is excluded.
Transitions
Transitions are defined on the Project level and stored in useTransitionsStore. All three kinds (fade, slide and wipe) are implemented in the preview and in export; see Editor: Transitions.
const engine = useTimelineEngine()
// Add a fade transition between two adjacent clips on the same track.
// trackId is required.
engine.addTransition({
fromClipId: clip1.id,
toClipId: clip2.id,
trackId: track.id,
kind: 'fade', // 'fade' | 'slide' | 'wipe'
durationFrames: 15, // 0.5 seconds at 30fps
easing: 'ease-out', // 'linear' | 'ease-in' | 'ease-out'
})
// The resolver handles opacity automatically:
// resolveTimeline(frame, project) → Scene
// During transition: fromClip.opacity interpolated 1→0
// toClip.opacity interpolated 0→1
//
// Preview: TransitionOverlay fades a CSS snapshot
// Export: globalAlpha mirrors the opacity values