Skip to content
Docs / Overview
Timeline

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.

Example: Timeline only
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]).

types.ts (data model)
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:

tsx
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:

TransportControls.tsx
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:

tsx
// 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.

tsx
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.

tsx
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

Keyboard Shortcuts

SpacePlay / Pause
SSplit selected clip at playhead
Delete / BackspaceDelete selected clip(s)
Ctrl/Cmd + CCopy selected clip(s)
Ctrl/Cmd + VPaste at playhead
Ctrl/Cmd + ZUndo
Ctrl/Cmd + Shift + ZRedo
Ctrl/Cmd + ScrollZoom timeline
← / →Step one frame
On this page