Quick Start
Three levels of integration — timeline only, timeline + preview, and the full editor. Start from whichever level matches your use case.
Level 1: Timeline Only
The Timeline component is the most isolated piece. Wrap it in EditorProvider and you get a fully functional NLE timeline — drag, trim, split, snap, undo/redo.
import { useRef } from 'react'
import {
EditorProvider,
Timeline,
useTimelineEngine,
type TimelineRef,
} from '@elah/editor'
function Controls() {
const engine = useTimelineEngine()
const addVideoClip = () => {
const tracks = engine.getProject().tracks
const videoTrack = tracks.find((t) => t.kind === 'video')
if (!videoTrack) return
engine.addClip({
trackId: videoTrack.id,
type: 'video',
src: 'my-video.mp4',
startFrame: 0,
durationFrames: 90, // 3 seconds at 30fps
name: 'My Clip',
})
}
return (
<div style={{ padding: 8, display: 'flex', gap: 8 }}>
<button onClick={addVideoClip}>Add Video Clip</button>
<button onClick={() => engine.undo()}>Undo</button>
<button onClick={() => engine.redo()}>Redo</button>
</div>
)
}
export default function TimelineOnly() {
const timelineRef = useRef<TimelineRef>(null)
return (
<EditorProvider
fps={30}
initialTracks={[
{ kind: 'video', name: 'Video' },
{ kind: 'audio', name: 'Audio' },
{ kind: 'text', name: 'Text' },
]}
>
<Controls />
<Timeline
ref={timelineRef}
fps={30}
style={{ height: 240 }}
/>
</EditorProvider>
)
}This is the same integration used in the live Timeline playground.
Open Timeline →Level 2: Timeline + Preview
Add <Preview> to mount the WebGL2 renderer and drive the RAF loop. You inject a demuxer factory so the SDK never hard-depends on a specific decode backend:
import { useRef } from 'react'
import {
EditorProvider,
Preview,
Timeline,
createDefaultDemuxerFactory,
type PreviewHandle,
type TimelineRef,
} from '@elah/editor'
// Zero-config demuxer — mediabunny ships bundled with @elah/editor
const demuxerFactory = createDefaultDemuxerFactory()
export default function App() {
const previewRef = useRef<PreviewHandle>(null)
const timelineRef = useRef<TimelineRef>(null)
return (
<EditorProvider fps={30}>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
}}>
{/* Preview takes up most of the vertical space */}
<Preview
ref={previewRef}
demuxerFactory={demuxerFactory}
enableAudio // default: true
style={{ flex: 1, minHeight: 0 }}
/>
{/* Timeline at the bottom */}
<Timeline
ref={timelineRef}
fps={30}
style={{ height: 240 }}
/>
</div>
</EditorProvider>
)
}<Preview> paints interactive transform overlays automatically — drag/resize for video and image clips, inline-edit for text clips. No additional wiring needed.
Level 3: Full Editor
Add <AssetPanel> for the media library (drag-drop import, filmstrip thumbnails, waveform previews), a transport toolbar, and export:
import { useRef } from 'react'
import {
EditorProvider,
AssetPanel,
Preview,
Timeline,
createDefaultDemuxerFactory,
usePlaybackStore,
useTimelineEngine,
framesToTimecode,
exportVideo,
type TimelineRef,
type PreviewHandle,
type InitialTrackConfig,
} from '@elah/editor'
const FPS = 30
const INITIAL_TRACKS: InitialTrackConfig[] = [
{ kind: 'video', name: 'Video / Image' },
{ kind: 'audio', name: 'Audio' },
{ kind: 'text', name: 'Text' },
]
const demuxerFactory = createDefaultDemuxerFactory()
function Toolbar() {
const engine = useTimelineEngine()
const { isPlaying, togglePlayPause, currentFrame } =
usePlaybackStore((s) => s)
const handleExport = async () => {
const project = engine.getProject()
const blob = await exportVideo(project, {
fps: FPS,
demuxerFactory,
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'export.mp4'
a.click()
}
return (
<div style={{ display: 'flex', gap: 8, padding: 8,
background: '#1a1a1a', color: '#fff' }}>
<button onClick={togglePlayPause}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>
{framesToTimecode(currentFrame, FPS)}
</span>
<button onClick={() => engine.undo()}>Undo</button>
<button onClick={() => engine.redo()}>Redo</button>
<button onClick={handleExport}>Export MP4</button>
</div>
)
}
export default function FullEditor() {
const timelineRef = useRef<TimelineRef>(null)
const previewRef = useRef<PreviewHandle>(null)
return (
<EditorProvider fps={FPS} initialTracks={INITIAL_TRACKS}>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
}}>
<Toolbar />
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
<AssetPanel style={{ width: 240, flexShrink: 0 }} />
<Preview
ref={previewRef}
demuxerFactory={demuxerFactory}
style={{ flex: 1, minWidth: 0 }}
/>
</div>
<Timeline
ref={timelineRef}
fps={FPS}
style={{ height: 240, flexShrink: 0 }}
/>
</div>
</EditorProvider>
)
}This is the same integration used in the live Production playground — asset panel, preview, timeline, transport, and export.
Open Production →Keyboard Shortcuts
The Timeline component handles these shortcuts automatically when it has focus:
Adding Clips Programmatically
Use useTimelineEngine() to access the engine from any child of EditorProvider. All mutations go through engine.addClip():
import { useTimelineEngine, useTracksStore, usePlaybackStore } from '@elah/editor'
export function AddClipButton() {
const engine = useTimelineEngine()
const tracks = useTracksStore((s) => s.tracks)
const currentFrame = usePlaybackStore((s) => s.currentFrame)
const addVideoClip = () => {
const videoTrack = tracks.find((t) => t.kind === 'video')
if (!videoTrack) return
engine.addClip({
trackId: videoTrack.id,
type: 'video',
src: 'my-video.mp4',
startFrame: currentFrame,
durationFrames: 90,
name: 'Clip',
})
}
const addTextClip = () => {
const textTrack = tracks.find((t) => t.kind === 'text')
if (!textTrack) return
engine.addClip({
trackId: textTrack.id,
type: 'text',
startFrame: currentFrame,
durationFrames: 60,
name: 'My Title',
text: {
content: 'Hello World',
fontSize: 48,
color: '#ffffff',
fontWeight: 'bold',
},
})
}
const addImageClip = () => {
const videoTrack = tracks.find((t) => t.kind === 'video')
if (!videoTrack) return
engine.addClip({
trackId: videoTrack.id,
type: 'image',
src: 'my-image.png',
startFrame: currentFrame,
durationFrames: 60,
name: 'Image',
})
}
return (
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={addVideoClip}>+ Video</button>
<button onClick={addTextClip}>+ Text</button>
<button onClick={addImageClip}>+ Image</button>
</div>
)
}Wrap multiple mutations in engine.batch() to commit them as a single undo entry:
engine.batch(() => {
engine.addClip({ trackId, type: 'video', ...videoOpts })
engine.addClip({ trackId: audioTrackId, type: 'audio', ...audioOpts })
}, 'Add video + audio')