The timeline structure is made of tracks (horizontal lanes) and clips (placed intervals inside lanes).
Understanding Tracks
A track describes row-level configuration and editing permissions:
interface Track { id: string; // Stable lane identifier kind: string; // App-defined lane category: e.g. "visual", "audio", "subtitle", "effect" clips: Clip[]; // Sorted clips inside the track selected: boolean; // Track selection state locked: boolean; // If true, edits are prevented on this lane muted: boolean; // If true, playback/routing is muted and UI may dim it visible: boolean; // If false, active media lookup ignores this lane height?: number; // Row layout height in pixels collapsed?: boolean; // Minimizes the track row UI name?: string; // User-facing track label targeted: boolean; // Receives operations like insert or paste groupId?: string; // Optional visual/header grouping for tracks}Understanding Clips
A clip represents a segment of media or action placed on the sequence timeline:
interface Clip { id: string; // Stable placement identifier timelineStart: RationalTime; // When the clip starts on the sequence timelineEnd: RationalTime; // When the clip ends on the sequence sourceStart: RationalTime; // Offset into the media asset where playback begins sourceId: string; // Identifier linking to your actual media/asset metadata selected: boolean; // Whether the clip is selected in the editor color?: string; // Optional background color override opacity?: number; // Optional opacity override label?: string; // Optional label text to draw on the clip movable?: boolean; // If false, prevents horizontal dragging resizable?: boolean; // If false, prevents edge trimming disabled?: boolean; // Suppresses rendering and playback sync minStart?: RationalTime; // Earliest boundary for trimming maxEnd?: RationalTime; // Latest boundary for trimming editPreview?: EditPreview; // Transient state details during an active edit gesture}Grouping Clips
Clip grouping is stored in top-level TimelineState.clipGroups, not on each
clip. A group links arbitrary clip ids at their existing timeline positions, so
clips can live on different tracks and still select, move, delete, copy, paste,
and split together.
interface TimelineClipGroup { id: string; // Stable group identifier clipIds: string[]; // Ordered clip ids in the group label?: string; // Optional app-facing group label}Use createClipGroup when clips already exist on the timeline. Use
insertClipGroup when your app has created multiple clips, such as video and
audio clips from one imported media file, and wants to place them on chosen
tracks in one history entry. Group insertion uses the same command-layer path as
commitEdit({ type: 'insert-clip-group' }), so validation, edit policy checks,
snapping, ripple behavior, lifecycle events, and undo history stay consistent
with other timeline edits.
engine.insertClipGroup({ groupId: 'import-1', placements: [ { clip: videoClip, targetTrackId: 'video-1', startTime }, { clip: audioClip, targetTrackId: 'audio-1', startTime }, ],});Canvas Timeline owns the generic edit behavior for groups. Your application still owns file picking, media inspection, waveform and thumbnail data, source metadata, track choice, and deciding whether imported media should be grouped.
Choosing Track Kinds
Canvas Timeline does not ship a fixed track-type enum. Your app owns the taxonomy by assigning track.kind strings that match its editing model.
For video-editor style applications, a practical starting point is:
visual: composited visual content such as video clips, images, SVGs, title cards, generated visuals, and nested sequences.audio: audible clips, music, voiceover, sound effects, and stems.subtitle: timed text, captions, translated subtitle tracks, and transcript overlays that should stay separate from visual content.effect: adjustment layers, transitions, filters, generators, or automation lanes that modify other content.
The source file format belongs in your asset store, not in track.kind. For example, an .mp4, .png, .svg, and generated title card can all appear on visual tracks while your app stores each asset’s MIME type, dimensions, decoding strategy, and renderer metadata behind sourceId.
Managing Track Header State
Canvas Timeline keeps common track-header semantics as first-party Track state
when the engine, renderer, history, hooks, or media synchronization need to agree
on behavior. Use muted for playback/output muting, visible for active media
lookup participation, locked for edit permission, targeted for edit
destinations, and collapsed/height for row layout.
Do not add arbitrary product state to Track. Keep app-specific header metadata
in a typed store keyed by stable track.id values:
interface AudioTrackMeta { recordArmed: boolean; routeId: string; meterSourceId: string;}
const audioTrackMetaById: Record<string, AudioTrackMeta> = { 'audio-1': { recordArmed: false, routeId: 'mix-bus', meterSourceId: 'meter-audio-1', },};Then compose first-party controls from Canvas Timeline hooks with your app-owned metadata:
import { useTimelineTrackHeader } from '@techsquidtv/canvas-timeline-react/hooks';
function AudioHeader({ trackId }: { trackId: string }) { const header = useTimelineTrackHeader(trackId); const audioMeta = audioTrackMetaById[trackId];
return ( <div {...header.rootProps}> <button type="button" onClick={() => header.toggleMute()}> {header.muted ? 'Unmute' : 'Mute'} </button> <button type="button" aria-pressed={audioMeta.recordArmed}> Record </button> </div> );}Clip body drags use track.kind as the default compatibility boundary for cross-track movement.
Dragging a clip into another unlocked track of the same kind previews and commits the transfer. Apps
that intentionally allow cross-kind moves can opt into that behavior through the headless clip drag
and track drop hooks.
import { fromSeconds } from '@techsquidtv/canvas-timeline';
const preview = engine.previewEdit({ type: 'move', clipId: 'clip-title', startTime: fromSeconds(4), targetTrackId: 'track-visual-2',});
if (preview.valid) { engine.commitEdit(preview.command);}React command hooks build the same command shapes and return TimelineCommandResult:
import { fromSeconds } from '@techsquidtv/canvas-timeline';import { useTimelineClips, useTimelineEditCommands,} from '@techsquidtv/canvas-timeline-react/hooks';
function MoveSelectedClip() { const { selectedClip } = useTimelineClips(); const { moveClip } = useTimelineEditCommands();
return ( <button disabled={!selectedClip} onClick={() => selectedClip && moveClip({ clipId: selectedClip.id, startTime: fromSeconds(4), targetTrackId: 'track-visual-2', }) } > Move </button> );}Dragging app-owned media onto the timeline uses native browser drag-and-drop through
useTimelineExternalClipDrop. Apps own payload formats, media inspection, source IDs, labels, and
placement factories. The hook owns event handling and track/time resolution, then commits single
insert/overwrite commands or grouped insert-clip-group/overwrite-clip-group commands through
the engine.
Some browsers do not expose custom DataTransfer values during dragover. When the drag source
lives in the same React app, keep the currently dragged asset id in app state or a ref and use it as
a fallback while still writing the canonical payload to DataTransfer for the final drop.
import { useRef } from 'react';import { useTimeline, useTimelineExternalClipDrop } from '@techsquidtv/canvas-timeline-react/hooks';
function ExternalAssetDropSurface() { const { state } = useTimeline(); const activeAssetIdRef = useRef<string | null>(null); const audioTrack = state.tracks.find((track) => track.kind === 'audio'); const drop = useTimelineExternalClipDrop({ editMode: 'overwrite', resolveDragData(event) { const assetId = event.dataTransfer.getData('application/x-my-asset') || activeAssetIdRef.current; return assetId === null ? null : { assetId }; }, createPlacements(context) { if (!audioTrack) { return null; }
return [ { clip: createVideoClip(context.data.assetId), targetTrackId: context.targetTrack.id, startTime: context.dropTime, }, { clip: createAudioClip(context.data.assetId), targetTrackId: audioTrack.id, startTime: context.dropTime, }, ]; }, canDropOnTrack: (context) => context.targetTrack.kind === 'visual', group: { label: 'Linked import' }, });
return ( <> <button type="button" draggable onDragStart={(event) => { activeAssetIdRef.current = 'asset-1'; event.dataTransfer.effectAllowed = 'copy'; event.dataTransfer.setData('application/x-my-asset', 'asset-1'); }} onDragEnd={() => { activeAssetIdRef.current = null; }} > External asset </button> <div {...drop.rootProps} data-valid-drop={drop.valid} /> </> );}For custom interaction layers, use useTimelineClipDrag with useTimelineTrackDropTargets and
useTimelineClipDropFeedback. The built-in drag policy accepts unlocked same-kind tracks by default.
Return allowCrossKindTrackMove: true only when your app intentionally permits a cross-kind transfer.
If you render custom feedback, disable the default canvas lane feedback with showClipDropFeedback={false}.
import { useTimelineClipDrag, useTimelineClipDropFeedback,} from '@techsquidtv/canvas-timeline-react/hooks';
function CustomClipDragLayer() { const drag = useTimelineClipDrag({ verticalSnapThreshold: 0.3, minVerticalSnapPixels: 8, canDropClipOnTrack({ sourceTrack, targetTrack }) { if (sourceTrack.kind === targetTrack.kind) { return true; }
if (sourceTrack.kind === 'visual' && targetTrack.kind === 'effect') { return { canDrop: true, reason: null, allowCrossKindTrackMove: true, }; }
return { canDrop: false, reason: 'incompatible-track-kind', allowCrossKindTrackMove: false, }; }, }); const feedback = useTimelineClipDropFeedback();
return ( <div data-drop-track={feedback.activeTargetTrackId ?? undefined}> {/* Connect pointer handlers to drag.startClipDrag, drag.moveClipDrag, and drag.endClipDrag. */} </div> );}