Live demo
Code example
import { TimelineEngine, TimelineProvider, Timeline, useTimeline, useTimelineTrackLockControl, CanvasRenderer, fromSeconds,} from '@techsquidtv/canvas-timeline';import { Eye, EyeOff, Lock, Unlock, Volume2, VolumeX } from 'lucide-react';import { useMemo } from 'react';import { Group as ResizablePanelGroup, Panel as ResizablePanel, Separator as ResizableHandle,} from 'react-resizable-panels';import { demoMarkers, demoTracks } from './timeline-demo-data';import { ControlBar } from './timeline-controls';import '@techsquidtv/canvas-timeline/styles.css';import './timeline-editor.css';
function TrackLockButton({ trackId }: { trackId: string }) { const lockControl = useTimelineTrackLockControl(trackId);
return ( <button {...lockControl.buttonProps} className="timeline-editor-track-header-button timeline-editor-track-header-lock-button" > {lockControl.locked ? <Lock aria-hidden="true" /> : <Unlock aria-hidden="true" />} </button> );}
function TrackHeaderColumn() { const { state } = useTimeline();
return ( <Timeline.TrackHeaderList className="timeline-editor-track-headers"> {state.tracks.map((track) => ( <Timeline.TrackHeader key={track.id} trackId={track.id}> {(header) => { const outputControl = header.kind === 'audio' ? ( <button type="button" className="timeline-editor-track-header-button" onClick={() => header.setMuted(!header.muted)} title={header.muted ? `Unmute ${header.label}` : `Mute ${header.label}`} aria-label={header.muted ? `Unmute ${header.label}` : `Mute ${header.label}`} aria-pressed={header.muted} > {header.muted ? <VolumeX aria-hidden="true" /> : <Volume2 aria-hidden="true" />} </button> ) : ( <button type="button" className="timeline-editor-track-header-button" onClick={() => header.setVisible(!header.visible)} title={header.visible ? `Hide ${header.label}` : `Show ${header.label}`} aria-label={header.visible ? `Hide ${header.label}` : `Show ${header.label}`} aria-pressed={!header.visible} > {header.visible ? <Eye aria-hidden="true" /> : <EyeOff aria-hidden="true" />} </button> );
return ( <div className="timeline-editor-track-header-content"> {outputControl} <TrackLockButton trackId={track.id} /> <span className="timeline-editor-track-header-label">{header.label}</span> <Timeline.TrackHeaderResizeHandle trackId={track.id} /> </div> ); }} </Timeline.TrackHeader> ))} </Timeline.TrackHeaderList> );}
function TimelineLayers() { const { state } = useTimeline();
return ( <> <Timeline.PlayheadArea /> <Timeline.PlayheadGrabber /> <Timeline.TrackList className="timeline-track-list-overlay"> {state.tracks.map((track) => ( <Timeline.Track key={track.id} trackId={track.id} /> ))} </Timeline.TrackList> <Timeline.ClipInteractionLayer /> <Timeline.RangeSelector /> </> );}
export function TimelineEditorControls() { const engine = useMemo( () => new TimelineEngine({ duration: fromSeconds(15), playheadTime: fromSeconds(2), zoomScale: 74, tracks: demoTracks, markers: demoMarkers, }), [] );
return ( <TimelineProvider engine={engine}> <div className="timeline-shell timeline-controls-shell timeline-editor-controls-shell"> <ControlBar />
<ResizablePanelGroup className="timeline-editor-body-with-headers" orientation="horizontal" resizeTargetMinimumSize={{ coarse: 28, fine: 8 }} > <ResizablePanel defaultSize="7.75rem" groupResizeBehavior="preserve-pixel-size" maxSize="20rem" minSize="7.75rem" > <div className="timeline-editor-header-panel"> <div className="timeline-stage timeline-editor-header-stage"> <TrackHeaderColumn /> </div> </div> </ResizablePanel>
<ResizableHandle aria-label="Resize track header column" className="timeline-editor-column-resize-handle" />
<ResizablePanel minSize="0"> <div className="timeline-editor-timeline-panel"> <div className="timeline-editor-stage-row"> <div className="timeline-stage timeline-editor-timeline-stage"> <Timeline.Root className="timeline-fill timeline-editor-root-with-headers"> <CanvasRenderer /> <TimelineLayers /> </Timeline.Root> </div> <div className="timeline-editor-vertical-scrollbar-column"> <Timeline.VerticalScrollbar className="timeline-editor-vertical-scrollbar"> <Timeline.VerticalScrollbarThumb className="timeline-editor-vertical-scrollbar-thumb"> <Timeline.VerticalScrollbarHandle side="start" /> <Timeline.VerticalScrollbarHandle side="end" /> </Timeline.VerticalScrollbarThumb> </Timeline.VerticalScrollbar> </div> </div> <div className="timeline-scrollbar-row timeline-editor-scrollbar-row"> <Timeline.ViewportScrollbar> <Timeline.ViewportScrollbarThumb> <Timeline.ViewportScrollbarHandle side="start" /> <Timeline.ViewportScrollbarHandle side="end" /> </Timeline.ViewportScrollbarThumb> </Timeline.ViewportScrollbar> </div> </div> </ResizablePanel> </ResizablePanelGroup> </div> </TimelineProvider> );}const demoClipColors = [ 'oklch(0.62 0.16 250)', 'oklch(0.68 0.14 145)', 'oklch(0.72 0.16 70)', 'oklch(0.65 0.17 25)', 'oklch(0.58 0.18 305)', 'oklch(0.64 0.12 195)',] as const;
export function getDemoClipColor(index: number) { return demoClipColors[index % demoClipColors.length];}import { useTimeline, useTimelinePlayback, useTimelineClips, useTimelinePlayheadTime, TimecodeField, clamp, compareRational, fromSeconds, toSeconds,} from '@techsquidtv/canvas-timeline';import { Magnet, MapPin, Pause, Play, Scissors, X } from 'lucide-react';import { useCallback } from 'react';
function PlayheadTimecodeControl() { const { engine, state } = useTimeline(); const playheadTime = useTimelinePlayheadTime();
const handleTimecodeCommit = useCallback( (seconds: number) => { const durationSeconds = state.duration !== undefined ? toSeconds(state.duration) : Number.POSITIVE_INFINITY; const bounded = clamp(seconds, 0, durationSeconds); engine.updatePlayhead(fromSeconds(bounded, playheadTime.r)); engine.settle(); }, [engine, playheadTime.r, state.duration] );
return ( <div className="timeline-timecode-control-wrapper"> <TimecodeField.Root ariaLabel="playhead timecode" onCommit={handleTimecodeCommit} value={playheadTime} > <TimecodeField.Trigger className="timeline-timecode-control-button" /> <TimecodeField.Input className="timeline-timecode-control-input" /> </TimecodeField.Root> </div> );}
function CutSelectedClipButton() { const playheadTime = useTimelinePlayheadTime(); const { selectedClip, splitClip } = useTimelineClips(); const canCutSelectedClip = selectedClip !== null && compareRational(playheadTime, selectedClip.timelineStart) > 0 && compareRational(playheadTime, selectedClip.timelineEnd) < 0; const cutButtonTitle = canCutSelectedClip ? 'Cut selected clip at playhead' : 'Select a clip and place the playhead inside it';
return ( <button type="button" className="timeline-control-button timeline-control-button-icon-only" onClick={() => selectedClip && splitClip(selectedClip.id, playheadTime)} title={cutButtonTitle} aria-label="Cut selected clip at playhead" disabled={!canCutSelectedClip} > <Scissors aria-hidden="true" /> </button> );}
// Control Bar Componentexport function ControlBar() { const { engine, state } = useTimeline(); const { pause, play, playing } = useTimelinePlayback();
const togglePlay = useCallback(() => { if (playing) { pause(); } else { play({ loop: true, respectInOut: true }); } }, [pause, play, playing]);
const hasInOutRange = state.inPoint !== undefined || state.outPoint !== undefined;
return ( <div className="timeline-control-bar"> {/* Play/Pause Button */} <button type="button" className="timeline-control-button timeline-control-button-icon-only" onClick={togglePlay} title="Play / Pause" aria-label={playing ? 'Pause timeline' : 'Play timeline'} > {playing ? <Pause aria-hidden="true" /> : <Play aria-hidden="true" />} </button>
{/* Timecode Editable display */} <PlayheadTimecodeControl />
<div className="timeline-control-divider" />
{/* In/Out markers buttons */} <button type="button" className="timeline-control-button" onClick={() => engine.setInPoint(engine.playheadTime)} title="Set In Point" aria-label="Set in point, In" > <span className="timeline-range-badge">I</span> In </button> <button type="button" className="timeline-control-button" onClick={() => engine.setOutPoint(engine.playheadTime)} title="Set Out Point" aria-label="Set out point, Out" > <span className="timeline-range-badge">O</span> Out </button>
<button type="button" className={`timeline-control-button timeline-control-button-icon-only${hasInOutRange ? '' : ' timeline-control-button-hidden'}`} onClick={() => engine.clearInOutPoints()} title="Clear Range" aria-label="Clear Range" disabled={!hasInOutRange} > <X aria-hidden="true" /> </button>
<div className="timeline-control-divider" />
{/* Snapping Toggle */} <button type="button" className={`timeline-control-button timeline-control-button-icon-only ${engine.isSnappingEnabled ? 'timeline-control-button-active' : ''}`} onClick={() => engine.setSnappingEnabled(!engine.isSnappingEnabled)} title={engine.isSnappingEnabled ? 'Disable snapping' : 'Enable snapping'} aria-label={engine.isSnappingEnabled ? 'Disable snapping' : 'Enable snapping'} aria-pressed={engine.isSnappingEnabled} > <Magnet aria-hidden="true" /> </button>
{/* Add Marker Button */} <button type="button" className="timeline-control-button timeline-control-button-icon-only" onClick={() => { engine.addMarker(engine.playheadTime, `M${(state.markers?.length ?? 0) + 1}`); }} title="Add marker" aria-label="Add marker" > <MapPin aria-hidden="true" /> </button>
<CutSelectedClipButton />
<div className="timeline-controls-right"> {/* Timeline Bounds Dropdown */} <div className="timeline-control-field"> <span className="timeline-control-field-label">Bounds:</span> <select className="timeline-control-select" value={ state.duration !== undefined ? Math.round(toSeconds(state.duration)).toString() : '' } onChange={(e) => { const durationSeconds = Number(e.target.value); engine.setDuration( e.target.value === '' ? undefined : fromSeconds(durationSeconds, state.duration?.r ?? engine.playheadTime.r) ); }} aria-label="Timeline bounds" > <option value="">Dynamic</option> <option value="15">15s</option> <option value="30">30s</option> <option value="60">60s</option> </select> </div> </div> </div> );}import { type Marker, type Track, fromSeconds } from '@techsquidtv/canvas-timeline';import { getDemoClipColor } from '../demo-clip-colors';
export const demoTracks: Track<'visual' | 'audio'>[] = [ { id: 'video-a', kind: 'visual', name: 'Video A', locked: false, muted: false, visible: true, selected: false, height: 48, clips: [ { id: 'intro', sourceId: 'vid-intro', timelineStart: fromSeconds(1), timelineEnd: fromSeconds(5.5), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(0), label: 'Intro sequence', }, { id: 'main', sourceId: 'vid-main', timelineStart: fromSeconds(6.5), timelineEnd: fromSeconds(12.5), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(1), label: 'Main feature clip', }, ], }, { id: 'video-b', kind: 'visual', name: 'Video B', locked: false, muted: false, visible: true, selected: false, height: 48, clips: [ { id: 'b-roll', sourceId: 'vid-b', timelineStart: fromSeconds(3), timelineEnd: fromSeconds(8.5), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(2), label: 'B-roll overlay', }, ], }, { id: 'audio-a', kind: 'audio', name: 'Audio A', locked: false, muted: false, visible: true, selected: false, height: 48, clips: [ { id: 'score', sourceId: 'aud-score', timelineStart: fromSeconds(0), timelineEnd: fromSeconds(15), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(3), label: 'Background score', }, ], }, { id: 'audio-b', kind: 'audio', name: 'Audio B', locked: false, muted: false, visible: true, selected: false, height: 48, clips: [ { id: 'dialogue', sourceId: 'aud-dialogue', timelineStart: fromSeconds(2.25), timelineEnd: fromSeconds(10.75), sourceStart: fromSeconds(0), selected: false, color: getDemoClipColor(4), label: 'Dialogue stem', }, ], },];
export const demoMarkers: Marker[] = [ { id: 'm1', time: fromSeconds(1), label: 'M1' }, { id: 'm2', time: fromSeconds(6.5), label: 'M2' },];.timeline-editor-body-with-headers { --demo-editor-scrollbar-gutter-size: 1.5rem; --demo-editor-scrollbar-track-size: 0.625rem; --demo-editor-scrollbar-gutter-padding: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size)) / 2 ); --demo-editor-scrollbar-gutter-padding-inline: calc( (var(--demo-editor-scrollbar-gutter-size) - var(--demo-editor-scrollbar-track-size) - 2px) / 2 ); --demo-editor-scrollbar-surface: var(--background);
width: 100%; min-width: 0; min-height: 0; overflow: hidden;}
.timeline-editor-header-panel,.timeline-editor-timeline-panel { display: grid; min-width: 0; min-height: 0; height: 100%; grid-template-rows: minmax(0, 1fr) auto; overflow: hidden;}
.timeline-editor-header-stage { min-height: 0; overflow: hidden;}
.timeline-editor-timeline-stage { min-height: 0;}
.timeline-editor-stage-row { display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(0, 1fr) auto;}
.timeline-editor-vertical-scrollbar-column { box-sizing: border-box; display: flex; width: var(--demo-editor-scrollbar-gutter-size); min-height: 0; align-items: stretch; justify-content: center; background: var(--demo-editor-scrollbar-surface); padding-block: var(--demo-editor-scrollbar-gutter-padding); padding-inline: var(--demo-editor-scrollbar-gutter-padding-inline);}
.timeline-editor-vertical-scrollbar { width: var(--demo-editor-scrollbar-track-size); min-height: 0;}
.timeline-editor-track-headers { width: 100%; height: 100%;}
.timeline-editor-root-with-headers .timeline-track-list-overlay { inset: 0;}
.timeline-editor-track-header-content { display: grid; width: 100%; min-width: 0; align-items: center; gap: 0.3rem;}
.timeline-editor-track-header-button { display: inline-grid; width: 1.45rem; height: 1.45rem; place-items: center; padding: 0; border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent); border-radius: calc(var(--radius) - 0.125rem); background: color-mix(in oklch, var(--background) 80%, transparent); color: var(--muted-foreground); cursor: pointer; transition: border-color 150ms ease, color 150ms ease, background-color 150ms ease, opacity 150ms ease;}
.timeline-editor-track-header-button svg { width: 0.85rem; height: 0.85rem;}
.timeline-editor-track-header-button:hover,.timeline-editor-track-header-button[aria-pressed='true'] { border-color: color-mix(in oklch, var(--primary) 55%, transparent); background: color-mix(in oklch, var(--primary) 12%, var(--background)); color: var(--foreground);}
.timeline-editor-track-header-button:disabled,.timeline-editor-track-header-button:disabled:hover { cursor: not-allowed; opacity: 0.52;}
.timeline-editor-track-header-label { min-width: 0; justify-self: start; overflow: hidden; color: var(--foreground); font-size: 0.7rem; text-align: left; text-overflow: ellipsis; white-space: nowrap;}
.timeline-editor-scrollbar-row { display: grid; min-height: var(--demo-editor-scrollbar-gutter-size); grid-template-columns: minmax(0, 1fr) var(--demo-editor-scrollbar-gutter-size); padding: 0; background: var(--demo-editor-scrollbar-surface);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar { width: auto; min-width: 0; height: var(--demo-editor-scrollbar-track-size); align-self: center; margin-inline: var(--demo-editor-scrollbar-gutter-padding);}
.timeline-editor-scrollbar-row .timeline-viewport-scrollbar-handle { width: var(--demo-editor-scrollbar-track-size);}
.timeline-editor-column-resize-handle { position: relative; width: 0.8rem; flex: 0 0 0.8rem; background: var(--muted); cursor: col-resize; outline: none;}
.timeline-editor-column-resize-handle::before { position: absolute; inset-block: 0; left: 50%; width: 2px; background: var(--border); content: ''; transform: translateX(-50%); transition: background-color 150ms ease, box-shadow 150ms ease;}
.timeline-editor-column-resize-handle:hover::before,.timeline-editor-column-resize-handle:focus-visible::before,.timeline-editor-column-resize-handle[data-resize-handle-active]::before { background: var(--primary); box-shadow: 0 0 0 2px color-mix(in oklch, var(--primary) 28%, transparent);}
.timeline-editor-controls-shell { --demo-editor-shell-height: 20rem; width: 100%; max-width: 100%; height: var(--demo-editor-shell-height); min-height: 0; grid-template-rows: auto minmax(0, 1fr);}
.timeline-editor-controls-shell .timeline-stage { min-height: 0;}
.timeline-editor-track-header-content { grid-template-columns: auto auto minmax(0, 1fr);}
.timeline-editor-track-header-lock-button[data-track-locked='false']:not(:hover) { border-color: color-mix(in oklch, var(--foreground) 8%, transparent); background: transparent; color: var(--muted-foreground);}