Your First Scene
Welcome! In this guide you'll build a small animation from scratch, one page at a time. By the end you'll have a title card that slides in, gets laid out neatly, picks up a blur, and finishes with a mask reveal. Each page adds onto the example from the page before, so follow along in order.
Let's start with the absolute basics: a scene, a single shape, and getting it on screen.
A scene is a document
In MotionScript a scene is data, not code. You describe it as a plain object: which nodes exist, what props they carry, and what happens to them over time. The engine works out what any frame looks like from that. Because a scene is plain JSON, it can be saved to a file, stored in a database, generated by a tool or sent to a server, and it renders the same everywhere.
There are two kinds of visual document:
- A still is a tree of nodes and nothing else. One frame, no timeline.
- A composition is a tree of nodes placed in time, plus a list of commands, each placed at a time. A node's row can say when it enters and leaves, which is how a composition says when each thing appears.
We'll write a composition, since the next page animates it. Give it a file of its own:
import type { CompositionDocument } from '@motionscript/sdk';
export default {
kind: 'composition',
nodes: [
// your nodes go here
],
commands: [
// your animation goes here
],
} 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.
Add your first node
A scene starts empty. To put something on screen, add a node: a row in the document's nodes. Let's add a blue rounded square using the rect node.
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: [],
} satisfies CompositionDocument;
That's it. You've placed a 200×200 blue square in the center of your scene. commands stays empty until the next page. Every node row has the same shape:
id: how commands refer to it. We'll animateboxon the next page.type: which kind of node it is:rect,ellipse,text,video, …parentId/layerOrder: where it sits in the tree.nullputs it directly in the scene, andlayerOrderorders it among its siblings.props: how it looks. List only what differs from the defaults.startAt/endAt: when it enters and leaves the scene, in seconds from the start. Leave them off, as here, and the node is there for the whole scene.
And the props we set:
width/height: the size in pixelsfill: the color (any CSS color name or hex value works)cornerRadius: how rounded the corners are
rect is the node you'll reach for most, but there are plenty of others. You can browse them all later under Available Components. For now, one square is all we need.
Play it
A document doesn't play itself. An engine does: it holds the renderer, knows which node types exist, and opens documents. Create one engine for your app and open as many documents on it as you like.
In a browser:
import { createBrowserEngine } from '@motionscript/browser';
import myScene from './scenes/my-scene';
// Serve canvaskit.wasm from @motionscript/canvaskit like any other asset.
const engine = await createBrowserEngine({ wasmUrl: '/canvaskit.wasm' });
const canvas = document.querySelector('canvas')!;
const session = engine.open({
document: myScene,
viewport: { width: 1920, height: 1080 },
fps: 60,
target: canvas,
});
session.playback?.play();
engine.open returns a session: one document bound to one canvas. viewport and fps describe the output rather than the scene, so you pass them here instead of writing them into the document.
In a React app, useMotionPlayer opens the session for you and MotionStage draws it:
import { useMotionPlayer, MotionStage, type Engine } from '@motionscript/react';
import myScene from './scenes/my-scene';
export function Preview({ engine }: { engine: Engine | null }) {
const player = useMotionPlayer({
engine,
document: myScene,
viewport: { width: 1920, height: 1080 },
fps: 60,
});
return <MotionStage player={player} />;
}
When you hand the player a different document, it swaps it into the running session rather than tearing the canvas down, so editing a scene keeps your place on the timeline.
Run it and you'll see your blue square sitting in the middle of the frame:
More than one scene: the project
A lone document is a one-scene video. To lay several clips out on one timeline, say an intro and then a demo with music under both, write a project document. It is a list of tracks, and each track places one clip at a time:
import type { ProjectDocument } from '@motionscript/sdk';
import intro from './intro';
import demo from './demo';
export default {
kind: 'project',
name: 'My First Animation',
fps: 60,
viewport: { width: 1920, height: 1080 },
tracks: [
{ id: 'intro', clip: intro, startAt: 0, duration: 3 },
{ id: 'demo', clip: demo, startAt: 3, duration: 6 },
],
} satisfies ProjectDocument;
- Each track places a
clip(a still, a composition, or an audio document) atstartAt, for adurationit must state. So a project's length is known from the document alone: it ends where its furthest track does. - Array order is paint order. The first track is at the back, so a background is the first track and an overlay is the last.
- A project carries its own
viewportandfps, so a host opens it with justdocument.
A project opens exactly like a single scene: engine.open({ document: reel }), or useMotionPlayer({ engine, document: reel }). In fact a single scene is opened as a project with one track.
The project entry: custom node types
A document names its node types by key and nothing more. The types themselves are code, TypeScript classes, and an engine only knows the ones it has been given. A project keeps its own in one entry file, src/project.ts (the path motionscript.json points at), exported as plain values:
import { PulseRing } from './components/pulse-ring';
import prism from './shaders/prism.json';
// Node types your documents may name, by `type`.
export const nodes = [PulseRing];
// Shader packs your documents may name.
export const shaders = { prism };
Hand the same values to the engine:
import { nodes, shaders } from './project';
const engine = await createBrowserEngine({ wasmUrl: '/canvaskit.wasm', nodes, shaders });
Now any document opened on that engine can add a pulseRing node. The built-in nodes are always available, so you don't need this file until you write a node type of your own; see Custom nodes. Anything a JSON value can't express, such as a live function or a computed lookup table, belongs in a node class.
ℹ Rendering from Node
@motionscript/headless renders the same documents from a script or a server. It bundles your project entry, so your node types come with it, and takes the document per request:
import { createServerEngine } from '@motionscript/headless';
const server = createServerEngine({ projectRoot: '.' });
const { bytes } = await server.renderImage({ document: myScene, frame: 'last' });
It doesn't move yet, but that's about to change.
Next up: making it animate.