React
Providers, hooks and unstyled components for building a Miraiclip editor in React.
@miraiclip/react puts a project and its preview into React context, gives you narrow hooks that re-render only when the slice they read changes, and ships unstyled components for the timeline, the preview, a seek bar, waveforms and on-canvas transform handles.
npm install @miraiclip/core @miraiclip/renderer @miraiclip/reactReact 18 and 19 are supported. The renderer is browser-only, so in Next.js render the editor in a client component.
Setup
Wrap the editor in ProjectProvider, then PlaybackProvider if it has a preview. Every hook below reads from these.
import { PlaybackProvider, ProjectProvider } from "@miraiclip/react";
import { PreviewPanel } from "./PreviewPanel";
import { StatusBar } from "./StatusBar";
import { TimelineTrack } from "./TimelineTrack";
export function App() {
return (
<ProjectProvider init={{ width: 1920, height: 1080, fps: 30 }}>
<PlaybackProvider volumeStorageKey="my-editor:volume">
<PreviewPanel />
<StatusBar />
<TimelineTrack trackId="main" pxPerSecond={100} />
</PlaybackProvider>
</ProjectProvider>
);
}Also uses PreviewPanel.tsx, StatusBar.tsx, TimelineTrack.tsx
import { Preview, usePlayback } from "@miraiclip/react";
// Inside <ProjectProvider> and <PlaybackProvider>.
export function PreviewPanel() {
const { isPlaying, toggle } = usePlayback();
return (
<>
{/* Hands its canvas to <PlaybackProvider>, keeps it sized, releases it on unmount. */}
<Preview width={640} height={360} />
<button type="button" onClick={toggle}>
{isPlaying ? "Pause" : "Play"}
</button>
</>
);
}import { useProjectState, useSelection, useUndoRedo } from "@miraiclip/react";
// Inside <ProjectProvider>. Each hook re-renders only when its slice changes.
export function StatusBar() {
const playheadUs = useProjectState((s) => s.playheadUs);
const selection = useSelection();
const { canUndo, undo } = useUndoRedo();
return (
<div>
{(playheadUs / 1e6).toFixed(2)}s · {selection.length} selected
<button disabled={!canUndo} onClick={undo}>Undo</button>
</div>
);
}import { Timeline, useTrack } from "@miraiclip/react";
// Inside <ProjectProvider> and <PlaybackProvider>.
// <Timeline.*> handles placement, select, drag (one clip/move on release), trim and snapping.
export function TimelineTrack({ trackId, pxPerSecond }: { trackId: string; pxPerSecond: number }) {
const track = useTrack(trackId);
if (!track) return null;
return (
<Timeline.Root pxPerSecond={pxPerSecond}>
<Timeline.Viewport style={{ height: 64 }}>
<Timeline.Track track={track} style={{ height: 48 }}>
<Timeline.Lane>
{(clip) => (
// State arrives as data attributes: style .clip[data-selected], .clip[data-dragging].
<Timeline.Clip clip={clip} className="clip" style={{ top: 4, bottom: 4 }}>
{clip.id}
<Timeline.TrimHandle side="start" style={{ width: 6 }} />
<Timeline.TrimHandle side="end" style={{ width: 6 }} />
</Timeline.Clip>
)}
</Timeline.Lane>
</Timeline.Track>
</Timeline.Viewport>
</Timeline.Root>
);
}| Provider | Props | Gives |
|---|---|---|
ProjectProvider | init (settings or a saved document) and optional seed, or project (one you created) | The project, via useProject() |
ProjectSessionProvider | initial: { init, seed? } | A project you can replace at runtime: useProjectSession() returns loadDocument(doc) (Open) and newProject() (New) |
PlaybackProvider | options (renderer and player options), volumeStorageKey | Transport, preview and volume, via usePlayback() |
seed runs once on a fresh project and is cleared from history, so users can't undo the demo content.
Reading state
| Hook | Returns | Re-renders when |
|---|---|---|
useProjectState(selector, isEqual?) | Any slice of state | The selected value changes |
useSettings() | Width, height, frame rate, name | Settings change |
useTracks(), useTrack(id), useTrackOrder() | Tracks in render order | Tracks change |
useClips(trackId), useClip(id), useClipCount(trackId) | A track's clips in time order | That track's clips change |
useTransitions(trackId) | A track's transitions | Those transitions change |
useSelection(), useSelectedClip() | Selected ids, or the one selected clip | Selection changes |
useDurationUs() | End of the last clip | A clip's placement changes |
usePlayheadUs() | Playhead in µs | Every played frame |
usePlayheadRef(onChange?) | A ref holding the playhead | Never |
useUndoRedo() | canUndo, canRedo, undo, redo | Availability changes |
The playhead updates every frame
usePlayheadUs re-renders 60 times a second during playback. Use it in small leaf components, or use usePlayheadRef and write to the DOM in onChange.
Keep selectors cheap and return stable values: useProjectState((s) => s.doc.clips[id]) is fine; building a new array on every call re-renders every time unless you pass shallowEqual as isEqual.
Editing
useCommands() returns typed builders for every built-in command, bound to the project:
import { useCommands, useSelectedClip } from "@miraiclip/react";
export function VolumeSlider() {
const clip = useSelectedClip();
const commands = useCommands();
if (!clip || !("volume" in clip)) return null;
return (
<input
type="range" min={0} max={1} step={0.01} value={clip.volume ?? 1}
onChange={(e) => commands.setClipProperty(clip.id, { volume: Number(e.target.value) })}
/>
);
}A slider like this commits one history entry per change. To make a whole drag one undo step, use useGesture(): call gesture.update(apply) on every move and gesture.end() on release (see Sliders and undo).
useEditorShortcuts(actions?) binds Delete/Backspace, S (split at the playhead), and undo/redo on window, ignoring keys typed into fields. Pass the actions you want, for example useEditorShortcuts(["undo", "redo"]).
Preview and playback
Render the canvas with <Preview>; build controls from usePlayback().
usePlayback() returns:
| Member | What it does |
|---|---|
attachCanvas(canvas, size), resizePreview(w, h), detachCanvas() | Hand the <canvas> to the renderer; size is its CSS size |
isPlaying, play(), pause(), toggle(), seek(us) | Transport. isPlaying follows the player's own events |
mode | "live", "fallback" (no renderer yet, or it failed: a clock still moves the playhead) or "unsupported" (no WebCodecs) |
lastError | The last renderer or media error, as text |
volume, muted, setVolume(v), toggleMute() | Monitoring volume. Never written to the project or exports |
getClipBounds(id), hitTest(x, y, filter?) | Where clips are drawn, for selection overlays and click-to-select |
controller | The underlying preview controller |
PlaybackProvider is safe under StrictMode: the double mount builds one renderer, not two on the same WebGL context.
Timeline hooks
| Hook | Use |
|---|---|
useVisibleRange(scrollRef, pxPerSecond, headerWidthPx?) | The time range to render, with overscan; mount only those clips |
useCompositorPlayhead(ref, toTransform, deps) | Moves a playhead line without re-rendering: the browser's compositor animates it during playback |
usePlayheadFollow(scrollRef, pxPerSecond, headerWidthPx?) | Pages the timeline to follow playback; side and reveal() for an off-screen indicator |
useTimelineZoom(scrollRef, options) | Ctrl/⌘ + wheel zooms around the pointer |
useTimelineZoom takes enabled: false to leave Ctrl/⌘ + wheel to the browser.
To build a timeline from these hooks instead of the components, see Build an editor › Timeline. The drag, snapping and range maths come from @miraiclip/editor, which works without React.
Components
Added in 0.2.0. Unstyled components that own an editor's interactions and expose their state as data attributes for your styles. Each has its own page with a live demo:
| Component | Hooks | Page |
|---|---|---|
<Timeline.*>: Root, Viewport, Ruler, Track, TrackHeader, Lane, Clip, TrimHandle, Playhead, Cuts, PlayheadFollow | useTimeline, useTimelineClip, useTimelineDrag, useTimelineRange, useTimelineTrack, rulerStepUs | Timeline |
<Preview> | usePreview | Preview |
<Scrubber.*>: Root, Range, Thumb | Scrubber | |
<Waveform> | useWaveformPeaks | Waveform |
<TransformBox.*>: Root, Outline, Guides, Body, Handle, RotateHandle, RotateLine | useTransformBox | Transform box |
See Components › Overview for how styling works.