Video timeline
NewA compositing editor’s timeline: layers with lock and eye, a ruler with markers and a red playhead, striped clips, keyframe curves, a waveform, and a play button that lifts, charges and rings.
video-timeline/video-timeline.tsxSpace plays and pauses; the arrow keys step a frame, with Shift ten; J, K and L play backwards, pause and play faster; plus and minus zoom; V and C pick the pointer or the razor.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Editing media against time: clips on stacked tracks that the reader moves, trims and splits, frame by frame.
- Animating properties: keyframes on a lane under their layer, joined by their curve, with a navigator to step between them.
- A playhead that scrubs, plays, loops and steps a frame, with a timecode that always reads where it is.
- Marking moments and stretches of an edit: flags on the ruler and named sections above the tracks.
- Recording a product film of the editor itself: the
presentationprop draws any frame exactly, with no clock of its own.
When not to use
- Plain playback of a finished video. Use a video player with a scrubber.
- A schedule of tasks over days or weeks. Use a project timeline, a Gantt chart.
- A story of events in order, with no lengths to edit. Use a vertical timeline or an activity feed.
- Trimming one clip to a start and an end. Use a range slider over its thumbnails.
- A phone-sized screen as the main place to edit: under 700 px the layers column narrows to lock, eye and name, and drags need room.
You can add this component with the shadcn CLI or by hand:
Into components/video-timeline of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/video-timeline.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { VideoTimeline } from './video-timeline';export function Edit() { return ( <VideoTimeline duration={8} fps={30} onTimeChange={(seconds) => console.log(seconds)} onTracksChange={(tracks) => console.log(tracks)} autoplay={false} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
Below are variations of this component, each live, with its code.
Film, frame by frame
The presentation prop driven by filmState(): the slider picks a frame of the 60 fps film and the editor draws exactly it.
The timeline of a motion-design tool, dark first. A layers column with tools, lock, eye, colour and a keyframe navigator beside a timecode and transport, a ruler with marker flags and sections, and tracks of clips: striped, solid or audio with its waveform, with keyframe diamonds joined by their curve. Play it, scrub the playhead, drag and trim clips, split them with the razor, add keyframes and markers, zoom the ruler. It can also be driven frame by frame, for a film of it: the entrance staggers the clips in, the panel’s outline lights up, and the play button lifts off its socket, charges blue under beams of light and rings as it is pressed.
- One clock: time in seconds is the playhead, the timecode and the keyframe navigator. Playing advances it on every animation frame by the real time elapsed (J and L play backwards and faster), looping at the end while Loop is on; every edit snaps to whole frames of fps.
- The layers column and the timeline share one vertical rhythm, a 46 px top bar, a 36 px ruler, 32 px rows and 27 px property lanes, so a layer and its track always line up; the ruler and the tracks scroll sideways together inside the timeline column, which is sized from the duration and the zoom (px per second).
- A clip is placed from its start and end. Dragging it moves it, its edges trim it, and both snap to the playhead, the markers and other clips’ edges within 6 px; the razor splits it where it is clicked. Arrow keys move a focused clip by a frame (Shift ten, Alt trims its end).
- Keyframes are diamonds on a property’s lane, joined by its curve, dotted from where the value holds. The diamond in a layer’s navigator adds or removes a keyframe at the playhead; the arrows beside it jump to the previous or next one.
- Presentation: given the presentation prop, it draws exactly that state, the time, playing, the entrance’s progress, the play button’s hover, lift, charge, beams and press, the outline, and runs no clock and no transition of its own, so a renderer can step it frame by frame. filmState(seconds) is a short film of it, written in frames of a 60 fps clock; the demo plays that film.
- The entrance grows each clip from its left edge in 44 frames on a cubic-bezier(.3, 0, .3, 1), ten frames apart, by clip-path so the rounded end shows; labels fade in after, then keyframes pop, their curves draw, the markers fade in and the playhead appears. The press drops the lifted button into its socket in 6 frames, swaps play for pause, and flashes a white ring that turns cyan and spreads into a halo.
- The play button’s looks are custom properties (--hover, --lift, --charge, --beams, --tint, --morph and the ring’s) mixed in CSS with color-mix() and calc(); a 3D renderer can set --vt-lift-depth to raise the button towards the camera and --vt-beams-transform to stand the beams up.
- Layout by container queries: under 700 px the layers column narrows to lock, eye, icon and name, and the transport moves to a row of its own.
| Prop | Type | Default | Description |
|---|---|---|---|
tracks | Track[] | the sample project | The tracks, top to bottom: { id, name, kind: layer | group | audio, tone, icon, striped?, clips: { id, start, end, label? }[], keyframes?, params?: { id, name, value, keyframes, holdFrom? }[], expanded?, hidden?, locked?, accent? }. |
markers | Marker[] | two markers | Flags on the ruler: { id, time, label, tone: blue | green | red | purple }. |
sections | Section[] | Open, Reveal, Title | The edit’s sections on the Sections row: { id, start, label, tone: blue | teal | purple }. |
duration | number | 8 | The timeline’s length, in seconds. |
fps | number | 30 | Frames per second: what stepping, dragging and trimming snap to. |
defaultTime | number | 1.7 | Where the playhead starts, in seconds. |
defaultZoom | number | 53 | The ruler’s scale to begin with, in px per second (20 to 360). |
defaultLoop | boolean | true | Playback loops at the end. |
defaultTool | 'select' | 'razor' | 'select' | The tool to open with: the pointer, or the razor that splits clips. Given, the demo stays off. |
defaultSelected | string | — | The id of a clip to open selected. Given, the demo stays off. |
presentation | Presentation | — | Draws exactly this state, with no clock or transition of its own: { time, playing, intro? (0–1), readout?, playhead?, tint?, hover?, lift?, charge?, beams?, press? (seconds since the press), outline? (0–1) }. filmState(seconds) returns the film’s. |
onTimeChange | (seconds: number) => void | — | Called as the playhead moves. |
onPlayingChange | (playing: boolean) => void | — | Called on play and pause. |
onTracksChange | (tracks: Track[]) => void | — | Called after an edit: a clip moved, trimmed, split or deleted, a keyframe, a toggle. |
onMarkersChange | (markers: Marker[]) => void | — | Called when a marker is added. |
autoplay | boolean | true | While on screen and untouched, the editor plays its short film. Any input hands it over. |
colorScheme | 'light' | 'dark' | 'system' | — | Light or dark colours, or the reader’s; without it, the page’s color-scheme. |
className | string | — | Extra class for the root, a stage of its own. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { memo, useEffect, useEffectEvent, useId, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent,} from 'react';import { INTRO_FRAMES, RATE, filmState, intro, morph, ring, type Presentation,/* Video timeline (video-timeline.tsx): a dark editor panel on a black stage — the layers column, the top bar with timecode and transport, the ruler, sections and tracks. Sizes are px at 13 px type: 46 px top bar, a 36 px ruler, 32 px rows, 27 px property lanes, a 282 px layers column. Colours are the tokens below, light-dark(light, dark): the editor takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Clip colours are the same on both. Presenting (a frame-by-frame renderer, or the autoplay film) turns every transition off: each frame is drawn from the state it is handed. The play button reads its effects from custom properties (--hover, --lift, --charge, --beams, --tint, --morph, --ring-*), set inline. For a 3D camera: --vt-lift-depth moves the lifted play button towards the viewer (translateZ), and --vt-beams-transform stands the light beams up (e.g. rotateX(-90deg)); both do nothing flat. */.root { --stage: #f1f2f4; --panel: #ffffff; --surface: #f7f8f9; --layers: #fbfbfc; --head: #f4f5f6; --track-bg: #f2f3f5; --ruler-bg: #f7f8f9; --edge: rgb(0 0 0 / 0.1);// Copyright (c) 2026 Bruno Delorence. MIT License./* * The timeline's motion, as numbers: when each part of the entrance plays, how the play button * lifts, charges and is pressed, and a whole short film of the editor as a function of time. * * Everything is counted in frames of a 60 fps clock, the rate the motion was measured at, and * turned into progress values (0 to 1) by pure functions. The component draws whatever state it is * handed, so a renderer that steps frame by frame gets the exact same picture every time. *//** The clock the motion is written in. */export const RATE = 60;/** How long the entrance lasts, in frames: clips, labels, keyframes, curves, markers, playhead. */export const INTRO_FRAMES = 152;/** The short film's length in seconds (see `filmState`). */export const FILM_SECONDS = 740 / RATE;/** The state a frame-by-frame renderer hands the component (its `presentation` prop). */export type Presentation = { /** Where the playhead is, in seconds. */ time: number;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The timeline's data: tracks with their clips and animated properties, ruler markers and the * sections of the edit, and the sample project the demo opens on. *//** A clip's colour: its fill, stripes and label ink come from it. */export type ClipTone = 'pink' | 'yellow' | 'magenta' | 'green' | 'blue' | 'purple';export type TrackIcon = 'sun' | 'type' | 'chip' | 'music' | 'shape' | 'image';export type Clip = { id: string; /** Start and end, in seconds. */ start: number; end: number; /** The label on the clip; the track's name without it. */ label?: string;};/** An animated property of a track, drawn as a lane of keyframes under it. */export type Param = { id: string; name: string;// Copyright (c) 2026 Bruno Delorence. MIT License./* * The editor's icons, drawn on a 16-unit grid in the text's colour (currentColor): each is a list * of shapes, filled or stroked with a round line of the given width. The stylesheet sizes them * (the lab page would otherwise force every unsized SVG to 16 px). */type Shape = { d: string; fill?: boolean; width?: number; evenodd?: boolean };const ring = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;/** The eight rays of the sun, from radius 4.4 to 6.4. */const rays = Array.from({ length: 8 }, (_, i) => { const a = (i * Math.PI) / 4; const [c, s] = [Math.cos(a), Math.sin(a)]; return `M${(8 + c * 4.4).toFixed(2)} ${(8 + s * 4.4).toFixed(2)}L${(8 + c * 6.3).toFixed(2)} ${(8 + s * 6.3).toFixed(2)}`;}).join('');export const ICONS = { pointer: [{ d: 'M3.6 2.4 12.9 7.1 8.7 8.5 7.2 12.9z', fill: true, width: 1.4 }], razor: [ { d: ring(4.6, 11.4, 2) + ring(11.4, 11.4, 2), width: 1.5 },// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver(- The playhead is a slider (its value in seconds, its text the timecode): the arrow keys step a frame, Shift ten, Home and End go to the ends. Space plays and pauses anywhere in the editor, J, K and L play backwards, pause and play faster.
- Tools, transport, loop, lock, eye, fold and keyframe buttons are real buttons with labels, pressed states and expanded states; the Add menu is a menu with arrow-key focus and Escape.
- Clips are focusable buttons named with their times: the arrow keys move one, Alt and the arrows trim its end, Delete removes it. Keyframes and marker flags are buttons that move the playhead to them.
- A polite live region says what changed: playing or paused, a split, a moved clip, a keyframe added or removed, a lock. The ruler’s labels, icons, beams and rings are hidden from screen readers.
- Under reduced motion the entrance, the beams, the lift and the ring are skipped and the looks change at once.