Miraiclip SDK
React components

Blocks

Small editors put together from the components.

A block is a few components arranged into something you can drop in and restyle. Copy one, then change the markup and classes.

Mini editor

A preview with transform handles, a player bar and a timeline: the core of a video editor in one file.

Loading demo…
Everything is live: select and drag on the preview or the timeline, play, scrub, undo.
MiniEditor.tsx
import type { Project } from "@miraiclip/core";
import { PlaybackProvider, ProjectProvider, useUndoRedo } from "@miraiclip/react";
import { PlayButton } from "./preview";
import { SeekBar } from "./scrubber";
import { EditorTimeline } from "./timeline";
import { CanvasEditor } from "./transform-box";

/** A small editor from the parts: preview with handles, a player bar and a timeline. */
export function MiniEditor({ seed }: { seed?: (project: Project) => void }) {
  return (
    <ProjectProvider init={{ width: 1280, height: 720, fps: 30 }} seed={seed}>
      <PlaybackProvider>
        <div className="overflow-hidden rounded-xl border border-neutral-800 bg-neutral-950">
          <CanvasEditor />
          <PlayerBar />
          <EditorTimeline />
        </div>
      </PlaybackProvider>
    </ProjectProvider>
  );
}

function PlayerBar() {
  const { canUndo, canRedo, undo, redo } = useUndoRedo();
  return (
    <div className="flex items-center gap-3 border-y border-neutral-800 px-3 py-2">
      <PlayButton />
      <div className="flex-1">
        <SeekBar />
      </div>
      <button type="button" onClick={undo} disabled={!canUndo} className="text-sm text-neutral-300 disabled:opacity-40">
        Undo
      </button>
      <button type="button" onClick={redo} disabled={!canRedo} className="text-sm text-neutral-300 disabled:opacity-40">
        Redo
      </button>
    </div>
  );
}
Also uses preview.tsx, scrubber.tsx, timeline.tsx, transform-box.tsx
preview.tsx
import { Preview, usePlayback } from "@miraiclip/react";

// Inside <ProjectProvider> and <PlaybackProvider>.
export function EditorPreview() {
  return (
    // No width/height on <Preview>: it fits the composition's aspect inside this box.
    <div className="flex h-80 items-center justify-center bg-neutral-900">
      <Preview fitPadding={16} className="overflow-hidden rounded-lg bg-black shadow-lg">
        {({ mode, lastError }) => (
          <>
            {mode === "unsupported" && (
              <p className="absolute inset-0 grid place-items-center p-4 text-center text-sm text-white/80">
                Live preview needs WebCodecs (Chrome, Edge or Safari 17+).
              </p>
            )}
            {lastError && (
              <p className="absolute bottom-2 left-2 rounded bg-red-600/90 px-2 py-0.5 text-xs text-white">{lastError}</p>
            )}
          </>
        )}
      </Preview>
    </div>
  );
}

export function PlayButton() {
  const { isPlaying, toggle } = usePlayback();
  return (
    <button type="button" onClick={toggle} className="rounded-md bg-sky-500 px-3 py-1 text-sm font-medium text-white">
      {isPlaying ? "Pause" : "Play"}
    </button>
  );
}
scrubber.tsx
import { formatClock } from "@miraiclip/editor";
import { Scrubber, useDurationUs, useProjectState } from "@miraiclip/react";

// Inside <ProjectProvider> and <PlaybackProvider>.
export function SeekBar() {
  // Whole seconds: this label re-renders once a second while playing, not every frame.
  const second = useProjectState((s) => Math.floor(s.playheadUs / 1_000_000));
  const durationUs = useDurationUs();

  return (
    <div className="flex items-center gap-3 text-xs tabular-nums text-neutral-400">
      <span className="w-10">{formatClock(second * 1_000_000)}</span>
      <Scrubber.Root className="h-5 flex-1 cursor-pointer rounded-full outline-none focus-visible:ring-2 focus-visible:ring-sky-400/60">
        {/* The track, then the played part on top of it (Range scales from its left edge). */}
        <div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 overflow-hidden rounded-full bg-neutral-700">
          <Scrubber.Range className="h-full w-full bg-sky-400" />
        </div>
        {/* Thumb is a full-size layer slid by the played fraction: the knob sits at its left edge. */}
        <Scrubber.Thumb>
          <div className="absolute top-1/2 left-0 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-sky-400 bg-white" />
        </Scrubber.Thumb>
      </Scrubber.Root>
      <span className="w-10 text-right">{formatClock(durationUs)}</span>
    </div>
  );
}
timeline.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>
  );
}
transform-box.tsx
import { Preview, TransformBox } from "@miraiclip/react";

const ACCENT = "#38bdf8";
const CORNERS = [0, 1, 2, 3] as const;

// Inside <ProjectProvider> and <PlaybackProvider>.
export function CanvasEditor() {
  return (
    <div className="flex h-80 items-center justify-center bg-neutral-900">
      <Preview fitPadding={24} className="bg-black">
        {/* Children of <Preview> sit over the canvas, at its size. */}
        <TransformBox.Root className="data-[dragging=move]:cursor-grabbing">
          <TransformBox.Outline target="hover" stroke={ACCENT} strokeOpacity={0.5} />
          <TransformBox.Guides stroke="#f472b6" strokeDasharray="4 3" />
          <TransformBox.Outline stroke={ACCENT} strokeWidth={1.5} />
          <TransformBox.RotateLine stroke={ACCENT} strokeWidth={1.5} />
          {/* Body: drag inside the box to move. Render it before the handles so they stay on top. */}
          <TransformBox.Body />
          {CORNERS.map((corner) => (
            <TransformBox.Handle
              key={corner}
              corner={corner}
              className="h-2.5 w-2.5 rounded-sm border border-sky-400 bg-white"
            />
          ))}
          <TransformBox.RotateHandle className="h-3 w-3 rounded-full border border-sky-400 bg-white" />
        </TransformBox.Root>
      </Preview>
    </div>
  );
}

It uses the examples from the other pages:

PartFrom
CanvasEditorTransform box
PlayButtonPreview
SeekBarScrubber
EditorTimelineTimeline

seed fills a fresh project once, and its edits are not undoable; the demo above passes one that adds the title cards. To open a saved project, pass the document as init instead.

Next steps

AddWith
Delete and split keysuseEditorShortcuts(["delete", "split", "undo", "redo"]), see React
Media importprobeMediaFile and placeAssetAtPlayhead, see Asset import
Audio clips with waveformsWaveform
A properties paneluseSelectedClip, useCommands and useGesture, see Properties panel
ExportExport UI

The full editor

The Editor UI kit is a complete, styled editor built on these components: text styles, effects, transitions, templates, captions, audio, an AI assistant and eight themes.

On this page