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: 'text', name: 'Text' },
]
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 text. V1 uses fixed 3-lane layout. Each track holds an array of Clip objects.
interface Track {
id: string
kind: 'video' | 'audio' | 'text'
name: string
muted: boolean
solo: boolean
zIndex: number
}
interface Clip {
id: string
trackId: string
type: 'video' | 'audio' | 'text' | 'image'
name: string
src?: string // URL or blob ref for video/audio/image
startFrame: number // integer — position on the timeline
durationFrames: number // integer — how long the clip is
trimInFrames: number // frames trimmed from the start of the source
trimOutFrames: number // frames trimmed from the end of the source
transform?: Transform // position, scale, rotation
text?: TextClipData // only for type: 'text'
opacity: number // 0..1, managed by transition system
zIndex: number
}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 position
engine.moveClip(clipId, { startFrame: 30, trackId: videoTrack.id })
// Remove a clip
engine.removeClip(clipId, trackId)
// Split clip at playhead position
import { splitClipAtPlayhead } from '@elah/editor'
splitClipAtPlayhead(engine, selectedClipId, currentFrame)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:
// Timeline accepts a snapTolerance prop (in pixels, default 6)
<Timeline
ref={ref}
fps={30}
snapTolerance={8}
style={{ height: 240 }}
/>
// Snap utilities are also exported for custom implementations
import {
snapFrame,
buildSnapPoints,
DEFAULT_OVERLAP_TOLERANCE,
} from '@elah/editor'
const snapPoints = buildSnapPoints(project, excludeClipId)
const snappedFrame = snapFrame(frame, snapPoints, tolerance)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
engine.addTransition({
fromClipId: clip1.id,
toClipId: clip2.id,
kind: 'fade',
durationFrames: 15, // 0.5 seconds at 30fps
easing: 'ease-in-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