Introduction
MotionScript is a TypeScript-first motion design engine for 2D and 3D animation. You describe a scene as a JSON document — the nodes in it, and commands placed on a timeline — and the engine renders any frame of it on demand, in a browser or in Node, and exports it to video.
Because a frame is a pure function of its time, scrubbing costs the same forwards or backwards, a scene's length is known before it plays, and a document can be stored, diffed or generated like any other data.
Add MotionScript to a project you already have, or scaffold a new one.
Connect your appReact · browser · NodeOpen a document on an engine and put it on a canvas.
Build your own componentsms addCopy a component's source into your project, or write a node type of your own.
Framework quickstartsReact · Node.jsThe same thing end to end, in the host you already have.
Install
MotionScript is published to npm as a set of packages. Add the ones your host needs — the engine-agnostic library, plus a rendering backend.
What each one is for:
| Package | What it does |
|---|---|
@motionscript/core | The engine. A peer dependency of the SDK and of every backend, so install it yourself: exactly one copy of it may be in your app, or class identity breaks and nodes render as plain shapes. |
@motionscript/sdk | The library itself: node types, document types, props, layout, and the JSX runtime. This is what your scenes import. |
@motionscript/browser | The web rendering backend, built on Skia/CanvasKit, plus video export and audio playback. |
@motionscript/canvaskit | The Skia WASM build the browser backend loads. Serve its canvaskit.wasm as a static asset. |
@motionscript/react | React bindings: a hook that owns the session and a component that draws it. Install this too if your host is React (it needs react and react-dom 19). |
@motionscript/headless | Renders the same documents from Node — a script, a queue, an HTTP handler. It drives a headless Chromium, so run npx playwright install chromium once after installing it. |
💡 Serving the WASM file
@motionscript/browser needs canvaskit.wasm at a URL it can fetch. Copy it out of @motionscript/canvaskit into your static directory as part of your build, then pass that URL as wasmUrl. Nothing else in the install needs wiring.
Connect your app
Whatever the host, the shape is the same: build one engine, open a document on it, get back a session. The engine holds the renderer and knows which node types exist. A session is one document bound to one surface. You make an engine once for the whole app and open as many documents on it as you like.
Here is the document the rest of this page opens — a blue square that slides right and fades out:
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 } },
],
commands: [
{ id: 'slide', type: 'to', targetId: 'box', at: 0, duration: 1, args: { x: 400 } },
{ id: 'fade', type: 'to', targetId: 'box', at: 1.5, duration: 0.5, args: { opacity: 0 } },
],
} satisfies CompositionDocument;
React
useMotionPlayer owns the session and MotionStage draws it. The hook takes the engine rather than building one, so it stays out of the business of picking a backend — hand it the browser engine, a test double, or nothing at all while yours is still loading.
import { useMotionPlayer, MotionStage, type Engine } from '@motionscript/react';
import scene from './scenes/intro';
export function Preview({ engine }: { engine: Engine | null }) {
const player = useMotionPlayer({
engine,
document: scene,
viewport: { width: 1920, height: 1080 },
fps: 60,
});
return <MotionStage player={player} />;
}
Building the engine is asynchronous, so useEngine returns null until it resolves, passes it down, and disposes it when the app unmounts:
import { createBrowserEngine } from '@motionscript/browser';
import { useEngine } from '@motionscript/react';
import { Preview } from './Preview';
export default function App() {
const engine = useEngine(() => createBrowserEngine({ wasmUrl: '/canvaskit.wasm' }));
return <Preview engine={engine} />;
}
Hand the player a different document and it swaps it into the running session rather than tearing the canvas down, so editing a scene keeps your place on the timeline.
Browser
Without a framework, engine.open does the same job directly. viewport and fps describe the output rather than the scene, so they are passed here instead of written into the document.
import { createBrowserEngine } from '@motionscript/browser';
import scene from './scenes/intro';
const engine = await createBrowserEngine({ wasmUrl: '/canvaskit.wasm' });
const session = engine.open({
document: scene,
viewport: { width: 1920, height: 1080 },
fps: 60,
target: document.querySelector('canvas')!,
});
session.playback?.play();
Node and servers
@motionscript/headless renders the same documents without a browser window. It drives the browser engine inside a headless page, which is the point: a server export and a reader's preview are the same code, so they are the same picture.
import { writeFile } from 'node:fs/promises';
import { createServerEngine } from '@motionscript/headless';
import scene from './scenes/intro.js';
const engine = createServerEngine({ projectRoot: '.' });
const video = await engine.renderVideo({ document: scene });
await writeFile('out.mp4', video);
const still = await engine.renderImage({ document: scene, frame: 'last' });
await writeFile('out.png', still.bytes);
await engine.close();
Each of these has a fuller walkthrough of its own — installing, wiring the WASM file, and a transport you can drive — under Framework quickstarts.
The engine bundles your project entry — src/project.ts, the path motionscript.json points at — so the node types your documents name come along with it. Documents are data and arrive per request, which is what lets one warm engine serve many renders.
Build your own components
A document names node types by key, and an engine only knows the ones it has been given. There are two ways to give it more.
Copy a component's source
Some components (Code, Latex, charts, diagrams) are not npm dependencies. ms add copies their source into your project — the same approach shadcn/ui popularized — so they are yours to edit, at the cost of picking up fixes by hand.
ms add code latex
The files land in src/components/<name>/ and you import them from the alias ms init wired up:
import { Code, lines, word } from '@/components/code';
A lockfile records which registry each file came from and a hash of the bytes written, so ms list and ms diff can answer the question copying otherwise makes unanswerable: is this file yours or ours?
Write a node type
When nothing in the registry fits, write the class yourself. A custom node is an ordinary class that paints whatever it likes during the render pass, while still laying out, animating and nesting like any other node. It is also the one place code joins a scene: anything JSON cannot express — a computed lookup table, a generated point cloud, a drawing built from a formula — belongs here.
import { Node2D, RenderContext2D, Graphics2D, node } from '@motionscript/sdk';
@node('blob')
export class Blob extends Node2D {
protected renderSelf(ctx: RenderContext2D): void {
ctx.draw(
new Graphics2D()
.ellipse({ width: 200, height: 200 })
.fill('tomato'),
);
}
}
@node('blob') only declares the document key — the key is written out rather than taken from the class name because documents store it, and a minifier is free to rename a class. List the class in your project entry and hand that to the engine:
import { Blob } from './components/blob';
export const nodes = [Blob];
import { nodes } from './project';
const engine = await createBrowserEngine({ wasmUrl: '/canvaskit.wasm', nodes });
Any document opened on that engine can now use it like a built-in:
{ id: 'blob', type: 'blob', parentId: null, layerOrder: 0,
props: { width: 400, height: 400 } },
The registry, the lockfile, and every flag ms add takes.
Base classes, animatable properties, and declaring assets.
GraphicsGraphics2DShapes, paints, cuts and masks recorded into one figure.
3D graphicsCanvas3DMeshes, materials, lights and cameras, painted into a 2D scene.