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…
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
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>
);
}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>
);
}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>
);
}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:
| Part | From |
|---|---|
CanvasEditor | Transform box |
PlayButton | Preview |
SeekBar | Scrubber |
EditorTimeline | Timeline |
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
| Add | With |
|---|---|
| Delete and split keys | useEditorShortcuts(["delete", "split", "undo", "redo"]), see React |
| Media import | probeMediaFile and placeAssetAtPlayhead, see Asset import |
| Audio clips with waveforms | Waveform |
| A properties panel | useSelectedClip, useCommands and useGesture, see Properties panel |
| Export | Export 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.