Miraiclip SDK
React components

Timeline

Tracks, clips, a ruler and a playhead, with drag, trim, snapping and zoom built in.

<Timeline.*> is a set of parts you nest to build a timeline. The parts handle layout in time, scrolling, zoom, clip move and trim with snapping, ruler seeking and the playhead. You decide how each part looks.

Loading demo…
Drag a clip, also onto another track. Select a clip and drag its edges to trim. Click or drag the ruler to seek. Ctrl/⌘ + wheel zooms around the pointer.

Example

EditorTimeline.tsx
import { useState } from "react";
import { Timeline, useTracks } from "@miraiclip/react";

const HEADER_WIDTH = 120;
const RULER_HEIGHT = 28;
const TRACK_HEIGHT = 48;

// Inside <ProjectProvider> and <PlaybackProvider>.
export function EditorTimeline() {
  const [pxPerSecond, setPxPerSecond] = useState(60);
  // useTracks() is in render order (bottom layer first); list the top layer first, like most editors.
  const tracks = useTracks().slice().reverse();

  return (
    <Timeline.Root
      pxPerSecond={pxPerSecond}
      onPxPerSecondChange={setPxPerSecond} // Ctrl/⌘ + wheel zooms around the pointer
      headerWidth={HEADER_WIDTH}
      className="relative flex h-64 flex-col bg-neutral-950 text-xs text-neutral-300"
    >
      <Timeline.Viewport className="min-h-0 flex-1">
        {/* Ruler row: sticks to the top; its first cell sticks to the left too. */}
        <div className="sticky top-0 z-30 flex" style={{ height: RULER_HEIGHT }}>
          <div
            className="sticky left-0 z-20 shrink-0 border-r border-b border-neutral-800 bg-neutral-950"
            style={{ width: HEADER_WIDTH }}
          />
          <Timeline.Ruler
            className="flex-1 cursor-pointer border-b border-neutral-800 bg-neutral-950"
            renderTick={(tick) => (
              <span
                className="absolute top-0 flex h-full items-center border-l border-neutral-800 pl-1 text-[10px] tabular-nums text-neutral-500"
                style={{ left: tick.x }}
              >
                {tick.label}
              </span>
            )}
          >
            <Timeline.Playhead className="z-10 h-full w-px bg-sky-400" />
          </Timeline.Ruler>
        </div>

        {tracks.map((track) => (
          <Timeline.Track key={track.id} track={track} style={{ height: TRACK_HEIGHT }}>
            <Timeline.TrackHeader className="flex items-center border-r border-b border-neutral-800 bg-neutral-950 px-3">
              {track.name}
            </Timeline.TrackHeader>
            <Timeline.Lane className="border-b border-neutral-900 data-[drop-target]:bg-sky-400/10">
              {(clip) => (
                <Timeline.Clip
                  clip={clip}
                  insetPx={2}
                  className="group top-1.5 bottom-1.5 flex cursor-grab items-center overflow-hidden rounded-md border border-sky-400/40 bg-sky-400/15 px-2 text-sky-100 data-[dragging]:z-10 data-[dragging]:cursor-grabbing data-[dragging]:shadow-lg data-[locked]:opacity-50 data-[selected]:ring-2 data-[selected]:ring-sky-400"
                >
                  <span className="pointer-events-none truncate">{"text" in clip ? clip.text : clip.kind}</span>
                  <Timeline.TrimHandle side="start" className="w-1.5 bg-sky-400 opacity-0 group-data-[selected]:opacity-100" />
                  <Timeline.TrimHandle side="end" className="w-1.5 bg-sky-400 opacity-0 group-data-[selected]:opacity-100" />
                </Timeline.Clip>
              )}
            </Timeline.Lane>
          </Timeline.Track>
        ))}

        {/* The playhead line over the lanes: a layer that starts where the lanes start. */}
        <div
          className="pointer-events-none absolute z-10"
          style={{ left: HEADER_WIDTH, top: RULER_HEIGHT, height: tracks.length * TRACK_HEIGHT }}
        >
          <Timeline.Playhead className="h-full w-px bg-sky-400" />
        </div>
      </Timeline.Viewport>
    </Timeline.Root>
  );
}

Anatomy

<Timeline.Root pxPerSecond={…}>          // zoom, drag state, context
  <Timeline.PlayheadFollow />            // optional: keeps the playhead in view
  <Timeline.Viewport>                    // the scroll container, both axes
    <Timeline.Ruler>                     // ticks; press or drag to seek
      <Timeline.Playhead />
    </Timeline.Ruler>
    <Timeline.Track track={track}>       // one row
      <Timeline.TrackHeader />           // sticks to the left edge
      <Timeline.Lane overlay={<Timeline.Cuts>…</Timeline.Cuts>}>
        {(clip) => (
          <Timeline.Clip clip={clip}>
            <Timeline.TrimHandle side="start" />
            <Timeline.TrimHandle side="end" />
          </Timeline.Clip>
        )}
      </Timeline.Lane>
    </Timeline.Track>
  </Timeline.Viewport>
</Timeline.Root>

Every part needs ProjectProvider and PlaybackProvider above it. Each part renders one <div> (except Cuts and PlayheadFollow, which render only what you return) and passes other props through to it.

Parts

Root

Holds the zoom and the clip being dragged, and gives every part below it the timeline's scale.

PropDefaultDescription
pxPerSecondrequiredZoom: horizontal pixels per second
onPxPerSecondChangeCalled by Ctrl/⌘ + wheel zoom, which keeps the time under the pointer fixed. Leave it out to turn wheel zoom off
minPxPerSecond, maxPxPerSecond4, 400Wheel zoom limits
headerWidth0Width of the sticky header column inside the viewport, px
lengthUscomposition + 5 s, at least 10 sHow far the timeline scrolls
snapPx8Clip edges snap to other clips, 0 and the playhead within this many pixels. 0 turns snapping off

data-dragging is "move", "trim-start" or "trim-end" while a clip is dragged.

Viewport

The scroll container for both axes. Its content is headerWidth plus the timeline's length wide. Give it a height (or flex-1 in a column) so it scrolls vertically. contentProps go to the inner element.

Ruler

Draws ticks for the visible range only. A press or drag on it seeks.

PropDefaultDescription
stepUsthe smallest step that keeps ticks 60 px apartTick spacing: a fixed value, or (pxPerSecond) => us
renderTicka <span data-tick> with the label(tick) => node, where tick is { us, x, label }. Place it at left: tick.x
childrenDrawn after the ticks, for example a <Timeline.Playhead> knob

rulerStepUs(pxPerSecond, minGapPx?) is the default step, if you want to reuse it.

Track and TrackHeader

<Timeline.Track track={track}> is one row (display: flex). It sets data-kind, data-locked, data-muted and data-hidden from the track. <Timeline.TrackHeader> is headerWidth wide and sticks to the left edge while the lanes scroll; put names and toggles in it.

Lane

Renders the track's clips through its children function. Only clips that overlap the visible range (plus a margin) are rendered, plus the clip being dragged, so a lane costs what is on screen whatever the project's length.

PropDefaultDescription
childrenrequired(clip) => node, usually a <Timeline.Clip>
overlayDrawn after the clips: cut markers, drop indicators
deselectOnClicktrueA click on the empty lane clears the selection

data-drop-target is set while a clip from another track is dragged over the lane.

Clip

Places a clip at its time and makes it draggable. A press selects it and starts a move; release commits one clip/move, which is one undo step. A clip can move to another track that accepts its kind. On touch, the first tap selects and a swipe scrolls the timeline; once selected, a drag moves the clip.

PropDefaultDescription
cliprequiredThe clip to draw
insetPx0Pixels taken off the left edge, for a gap between neighbouring clips
minWidthPx2Narrowest drawn width
childrenNodes, or (state) => node with the state below
Data attributeSet when
data-selectedThe clip is selected
data-dragging"move", "trim-start" or "trim-end" while this clip is dragged
data-lockedIts track is locked: the clip can be selected but not dragged
data-kindAlways: the clip kind (video, audio, text, …)

The clip's left and width come from its live position, so during a drag it moves under the pointer before anything is committed. Set its vertical position yourself (top/bottom or height).

useTimelineClip() returns the same state inside a clip:

FieldDescription
clipThe clip
startUs, durationUsLive position: the drag preview while dragged
trimStartUsMedia in-point, live while trimming the start (0 for clips without media)
selected, lockedAs the data attributes
draggingThe drag mode, or null

TrimHandle

A handle on the start or end edge of the enclosing clip. Dragging it trims the clip, keeping at least 200 ms, and moves the media in-point for video and audio. It is absolutely placed at full height on its edge; give it a width. data-active is set while it is dragged.

Playhead

The playhead, moved on the compositor: no React render or style recalculation per frame. It sits at its parent's left edge, so put it in the ruler, or in an absolutely placed layer that starts where the lanes start (as in the example). Style the line and add a knob as children.

Cuts

Renders a node for each cut on the track: a place where one visual clip ends exactly where the next starts, which is where a transition can go. Use it as a lane's overlay. Only cuts in view are passed, and markers are hidden while a clip is dragged (hideWhileDragging, default true).

<Timeline.Lane
  overlay={
    <Timeline.Cuts>
      {({ x, from, to, transition }) => (
        <button
          type="button"
          className="absolute top-1 h-4 w-4 -translate-x-1/2 rounded-full bg-white text-[10px]"
          style={{ left: x }}
          onClick={() => openTransitionPicker(from.id, to.id)}
        >
          {transition ? "◆" : "+"}
        </button>
      )}
    </Timeline.Cuts>
  }
>
  {(clip) => <Timeline.Clip clip={clip} />}
</Timeline.Lane>

x is the cut's offset from the lane's left edge; atUs, from, to and transition come from trackCuts in @miraiclip/editor.

PlayheadFollow

While playing, pages the timeline so the playhead stays in view, pausing for 1.5 s after the user scrolls by hand. Its children function receives where the playhead is when it is off screen, for a "jump to playhead" button. It re-renders on its own, never the timeline.

<Timeline.PlayheadFollow>
  {({ side, us, reveal }) =>
    side && (
      <button type="button" onClick={reveal} className="absolute top-1 z-40 rounded-full bg-sky-500 px-2 text-xs text-white" style={side === "left" ? { left: 128 } : { right: 8 }}>
        {formatClock(us)}
      </button>
    )
  }
</Timeline.PlayheadFollow>

Hooks

HookReturnsUse inside
useTimeline()pxPerSecond, headerWidth, lengthUs, viewportRef, usAtClientX(x), beginClipDrag(event, clip, mode)Root
useTimelineRange(){ fromUs, toUs } in view, with a marginRoot; for virtualising your own rows
useTimelineDrag()The dragged clip as it would land now (ClipDragPreview), or nullRoot
useTimelineTrack()The enclosing trackTrack
useTimelineClip()The clip state aboveClip

beginClipDrag is what Clip and TrimHandle call, so a custom handle can start a move or trim from its own onPointerDown.

Customising

ToDo
Use a different clip look per kindBranch on clip.kind in the lane's children, or style [data-kind="audio"]
Draw audioPut a <Waveform> inside the clip
Keep zoom in your own storepxPerSecond and onPxPerSecondChange are controlled props; wire them to a zoom slider too
Turn snapping offsnapPx={0}
Add keyboard shortcutsuseEditorShortcuts(["delete", "split"]) from the React package
Build the timeline without these partsUse the hooks (useVisibleRange, useCompositorPlayhead, useTimelineZoom) and beginClipDrag, as in Build an editor › Timeline

On this page