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). V1 uses a fixed 3-lane 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
}

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:

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

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.

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',
  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