Latest Version0.1.0-alpha.5
motionscript

React quickstart

Put a MotionScript scene on screen in a React app, with a play/pause button under it. About five minutes, start to finish.

1. Install

You need the engine, the library, a rendering backend, the Skia WASM build it loads, and the React bindings. @motionscript/core is a peer dependency of the other four, so it is listed explicitly: your app must hold exactly one copy of it.

bash
$npm install @motionscript/core @motionscript/sdk @motionscript/react @motionscript/browser @motionscript/canvaskit

2. Serve canvaskit.wasm

The browser backend fetches the Skia WASM binary at runtime, so it has to be reachable at a URL. Copy it out of the package into your static directory as part of the build:

{
  "scripts": {
    "prebuild": "cp node_modules/@motionscript/canvaskit/canvaskit.wasm public/",
    "predev": "cp node_modules/@motionscript/canvaskit/canvaskit.wasm public/"
  }
}

public/ is Vite's static directory; use whatever yours is. Whatever URL it ends up at is what you pass as wasmUrl below.

⚠ Caution

Skip this and the first frame fails with a fetch error for canvaskit.wasm. It is the one piece of wiring the install cannot do for you.

3. Write a scene

A scene is data: a list of nodes, and a list of commands placed on a timeline.

import type { CompositionDocument } from '@motionscript/sdk';

export default {
  kind: 'composition',
  nodes: [
    { id: 'box', type: 'rect', parentId: null, layerOrder: 0,
      props: { width: 200, height: 200, fill: 'royalblue', cornerRadius: 16 } },
    { id: 'label', type: 'text', parentId: 'box', layerOrder: 0,
      props: { text: 'hello', fontSize: 32, fill: 'white' } },
  ],
  commands: [
    { id: 'slide', type: 'to', targetId: 'box', at: 0, duration: 1,
      easing: { kind: 'out', args: 'back' }, args: { x: 400 } },
    { id: 'fade', type: 'to', targetId: 'box', at: 1.5, duration: 0.5, args: { opacity: 0 } },
  ],
} satisfies CompositionDocument;

satisfies CompositionDocument is optional — it gets your editor to check the shape while the object stays plain data. A .json file works just as well.

4. Build the engine

An engine holds the renderer and knows which node types exist. Building one is asynchronous, and useEngine does the waiting for you: give it a function that builds the engine, and it returns null until the engine is ready. Make one for the whole app and open as many documents on it as you like — a fresh engine per render rebuilds the WebGL surface behind every player on it.

import { createBrowserEngine } from '@motionscript/browser';
import { useEngine } from '@motionscript/react';

const engine = useEngine(() => createBrowserEngine({ wasmUrl: '/canvaskit.wasm' }));

It disposes the engine when the component unmounts, including the case where it unmounted while the wasm was still loading, which StrictMode does on every mount in development. If building fails, the error is logged; a missing canvaskit.wasm from step 2 surfaces there. Two options change that:

  • onError receives the error instead of console.error, for a toast or your error reporter.
  • deps rebuilds the engine when something that makes a different one changes, such as the list of custom nodes. The old engine is disposed first and the hook returns null until the new one is ready. It defaults to [], built once. The function you pass is always the latest one, so an inline arrow never rebuilds anything on its own.
const engine = useEngine(() => createBrowserEngine({ wasmUrl, nodes }), {
  deps: [nodes],
  onError: (error) => reportError(error),
});

Anything that has to be true before a player sees the engine, such as engine.setManifest(...), goes inside the function you pass, before it returns.

5. Play it

useMotionPlayer opens the session and owns the transport; MotionStage is the canvas bound to it.

import { createBrowserEngine } from '@motionscript/browser';
import { useEngine, useMotionPlayer, MotionStage, usePlayhead } from '@motionscript/react';
import scene from './scenes/intro';

export function Preview() {
  const engine = useEngine(() => createBrowserEngine({ wasmUrl: '/canvaskit.wasm' }));

  const player = useMotionPlayer({
    engine,
    document: scene,
    viewport: { width: 1920, height: 1080 },
    fps: 60,
    loop: true,
  });

  // Only this component re-renders as the playhead moves — the frame number
  // lives outside React, so the tree above it is not re-rendered at clock rate.
  const frame = usePlayhead(player);

  return (
    <div>
      <MotionStage player={player} renderScale={0.5} />

      <div>
        <button onClick={() => player.toggle()}>
          {player.isPlaying ? 'Pause' : 'Play'}
        </button>
        <input
          type="range"
          min={0}
          max={player.totalFrames - 1}
          value={frame}
          onChange={(e) => player.seek(Number(e.target.value))}
        />
        <span>
          {frame} / {player.totalFrames - 1}
        </span>
      </div>
    </div>
  );
}

That is the whole integration. Things worth knowing about it:

  • engine is nullable on purpose. The player no-ops until one arrives, which is what keeps the hook out of a conditional — React forbids that.
  • renderScale is not a zoom. It is how many device pixels are rasterized per viewport unit. A 1920×1080 project in a 700px-wide panel rasterizes about seven times the pixels anyone can see; 0.5 is a quarter of the fill, the shader work and the effect offscreens. Quantize it to a few steps rather than driving it from a resize observer — every change re-creates the Skia surface.
  • A new document keeps your place. Hand the player a different document and it is reconciled onto the running session — tracks patched in place, playhead where it was, playback still running. Treat documents as immutable: an edit made in place to one the player already holds is not seen.
  • One session drives one surface, so one stage per player. Two independent timelines are two useMotionPlayer calls sharing one engine.

Where to go next