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). V1 uses a fixed 3-lane 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
}
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
// 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. 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)Transitions
Transitions are defined on the Project level and stored in useTransitionsStore. The fade transition is fully implemented; slide/wipe transitions have architecture in place.
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',
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