Introduction
The built-in nodes cover most of what a scene needs. When they do not, you write your own. A custom node is an ordinary class that extends Node and paints whatever it likes during the render pass, while still laying out, animating and nesting like any other node.
A custom node is also the one place code joins a scene. A document is plain JSON, so anything it can't express, such as a computed lookup table, a generated point cloud or a drawing built from a formula, belongs in a node class that documents name by key.
There are two things a custom node can draw:
- 2D figures, with the Graphics2D API. Shapes, paints, cuts and masks recorded into one list.
- 3D scenes, with the 3D Graphics2D API. Meshes, lights and a camera.
Both work the same way. You build a value, then hand it over inside renderSelf.
Your first custom node
import { Node2D, RenderContext2D, Graphics2D, node } from '@motionscript/sdk';
@node('blob')
class Blob extends Node2D {
protected renderSelf(ctx: RenderContext2D): void {
ctx.draw(
new Graphics2D()
.ellipse({ width: 200, height: 200 })
.fill('tomato'),
);
}
}
@node('blob') gives the class its document key. It only declares the key; the class becomes available when you hand it to an engine:
import { createBrowserEngine } from '@motionscript/browser';
const engine = await createBrowserEngine({ wasmUrl: '/canvaskit.wasm', nodes: [Blob] });
Any document opened on that engine can now use it like a built-in node:
{ id: 'blob', type: 'blob', parentId: null, layerOrder: 0,
props: { width: 400, height: 400 } },
{ id: 'hello', type: 'text', parentId: 'blob', layerOrder: 0,
props: { text: 'hello', fontSize: 48 } },
renderSelf draws this node only. Children still render on top of it, so the text sits over the blob. The node accepts anchors, participates in flex layout, and takes commands like anything else.
The key is written out rather than taken from the class name because documents store it: a minifier is free to rename the class, and every saved document would silently stop matching. In a project, list your node types in the nodes export of src/project.ts, so every host that opens your documents is given them (see Your First Scene).
Choosing a base class
Extend the one whose behaviour you want to keep.
| Base | You get | Use when |
|---|---|---|
Node | Layout, transform, children. Nothing drawn | The drawing is entirely yours |
ShapeNode | fill, stroke, shadow, start/end props | You want the standard paint props on your figure |
Rect or another shape | Everything above, plus that shape's geometry, cornerRadius and clip | You are extending an existing shape |
Canvas3D | A rect that paints a 3D scene | You are building a reusable 3D component |
Extending ShapeNode gives your node fill/stroke/shadow props that you can read straight into your paint calls:
import { ShapeNode, ShapeProps, RenderContext2D, Graphics2D, node, property } from '@motionscript/sdk';
export interface GaugeProps extends ShapeProps {
value: number;
}
@node('gauge')
export class Gauge extends ShapeNode<GaugeProps> {
@property({ default: 0 }) declare readonly value: number;
protected renderSelf(ctx: RenderContext2D): void {
ctx.draw(
new Graphics2D()
.ellipse({
width: 300, height: 300, ratio: 0.75,
startAngle: -90, sweep: 360 * this.value,
})
.shadow(this.shadow)
.fill(this.fill)
.stroke(this.stroke),
);
}
}
{ id: 'gauge', type: 'gauge', parentId: null, layerOrder: 0,
props: { value: 0.35, fill: '#6990DD' } }
Adding props
@property declares an animatable prop. Give it a default, and read it while drawing:
@property({ default: 0 }) declare readonly value: number;
That is all a number needs. Anything richer, such as a fill, a colour or a corner radius, needs a mapper and a tween as well, or to will snap instead of animating. The attribute-typed decorators carry the right pair for you:
@fillProperty({ default: 'white/10' }) declare glow: Fill;
@colorProperty({ default: 'white' }) declare ink: Color;
See Properties for the full set and the rules around them.
Animating a drawing
You cannot animate a recorded drawing command, because the list is already built. Animation comes from the fact that renderSelf runs every frame, so the figure is rebuilt from whatever the current values are.
From node props
nodes: [
{ id: 'gauge', type: 'gauge', parentId: null, layerOrder: 0,
props: { value: 0, fill: '#6990DD' } },
],
commands: [
{ id: 'fill-up', type: 'to', targetId: 'gauge', at: 0, duration: 1.2,
easing: { kind: 'inOut', args: 'quad' }, args: { value: 0.8 } },
],
A prop tweened with to is the least a node can offer. A reusable component publishes commands of its own, as the built-ins do with moveTo and fadeTo: fillUp says what happens, takes its own parameters, and shows on a timeline under its own name.
From a bound prop
A prop can hold a function instead of a value, and it is re-evaluated every time it is read. That is how one value drives several figures without each of them being tweened separately. Inside a composite node's constructor, one piece can follow another:
class Trace extends Node2D {
@property({ default: 0 }) declare readonly progress: number;
protected renderSelf(ctx: RenderContext2D): void {
ctx.draw(
new Graphics2D()
.path({ data: route, end: this.progress })
.stroke({ weight: 8, fill: 'white' }),
);
}
}
const lead = createRef<Trace>();
<Trace ref={lead} />
<Trace progress={() => lead().progress * 0.5} /> {/* follows, at half speed */}
In a document, the same job belongs to a variable: one variableTo command,
and any number of props bound to it with an expression ("end": "=[progress]").
From the clock
For motion that is not driven by a tween, read the node's clock inside renderSelf:
protected renderSelf(ctx: RenderContext2D): void {
const t = this.time.elapsed;
ctx.draw(new Graphics2D().rect({ rotation: t * 90, width: 100, height: 100 }).fill('white'));
}
💡 Tip
A bound prop such as someProp={() => f(this.time.elapsed)} works too — a
binding is re-evaluated on every read, so it sees the current frame's clock.
A tweened prop is still usually better, because the motion is then described in the document and shows up on the timeline as something you can see and re-time, rather than being derived from how long the node has existed.
Never accumulate
Build a new value every frame. Do not keep one on the instance and append to it:
// Wrong. Commands pile up forever, and the figure depends on how the
// playhead got here rather than where it is.
private g = new Graphics2D();
protected renderSelf(ctx: RenderContext2D) {
this.g.rect({ /* ... */ });
ctx.draw(this.g);
}
// Right. Frame 10 looks the same however you reached it.
protected renderSelf(ctx: RenderContext2D) {
ctx.draw(new Graphics2D().rect({ /* ... */ }));
}
Rebuilding is cheap. A drawing is an array of plain objects, and the renderer does the expensive work.
Composite nodes
If your component is really several nodes arranged together, do not draw it. Build the subtree in the constructor:
interface BadgeGroupProps extends Node2DProps {
labels: string[];
spacing: number;
}
@node('badgeGroup')
class BadgeGroup extends Node2D<BadgeGroupProps> {
@property({ default: 32 }) declare readonly spacing: number;
constructor(props?: NodeConfig<BadgeGroup, BadgeGroupProps>) {
super(props);
this.add(
<Rect flow="horizontal" gap={() => this.spacing}>
{(props?.labels ?? []).map(label => (
<Rect width={180} height={180} cornerRadius={24}>
<Text text={label} fontSize={56} />
</Rect>
))}
</Rect>,
);
}
}
The constructor is the right place because composition only needs props, and it runs once per instance, so there is nothing to accumulate.
A document can't reach inside a composite: commands address nodes by id, and the pieces a constructor builds have none. So give the composite a prop for anything that should animate, and bind the piece to it. gap={() => this.spacing} is re-read every frame, so tweening spacing moves the row:
{ id: 'spread', type: 'to', targetId: 'badges', at: 0, duration: 1.2, args: { spacing: 80 } }
For choreography one prop can't express, give the class a @command() method. It becomes a command type that documents can name.
Rule of thumb: if the pieces need layout or independent animation, they are nodes. If they are one figure that happens to have many parts, they are drawing commands.
Nodes that need other nodes
Some nodes only work in a particular place: a tangent needs a curve, a curve needs axes. Say so on the class, and a host can offer the type only where it works, refuse a drop anywhere else, and tell an author why a node draws nothing.
// Goes only inside a graph, and takes only children made for it.
@node({ key: 'curve', within: ['graph'], closed: true, layout: { acceptsChildren: true } })
class Curve extends ShapeNode<CurveProps> { /* … */ }
// Goes only inside a curve.
@node({ key: 'tangent', within: ['curve'] })
class Tangent extends ShapeNode<TangentProps> { /* … */ }
// Sits in the graph beside the curves it reads, and needs at least one there.
@node({ key: 'region', within: ['graph'], beside: ['curve'] })
class Region extends ShapeNode<RegionProps> { /* … */ }
within | The types this one goes inside. A name also covers every type built on it (parentKey). Left out, it goes anywhere |
beside | Types it needs at least one of as a sibling. Use it where the thing needed is shared, or there may be several |
closed | The node takes only children that name it in their within |
These are statements about the class, not checks at build time: a node in the
wrong place still builds. parentIssue(registry, child, parent) and
siblingIssue(registry, child, siblings) answer for one placement, and
placementIssues(registry, document.nodes) lists every node of a document that
is not where it goes.
Choose a parent when the node is of the other one and should appear, move and leave with it. Choose a sibling when it has a life of its own on the timeline.
A worked example
A bar chart node: gridlines, bars and labels, all drawn, with one prop that animates the whole thing.
import {
ShapeNode, ShapeProps, RenderContext2D, Graphics2D, Fills, node, property,
} from '@motionscript/sdk';
export interface BarChartProps extends ShapeProps {
data: number[];
/** 0 is flat, 1 is full height. Animate this to grow the bars in. */
progress: number;
}
@node('barChart')
export class BarChart extends ShapeNode<BarChartProps> {
@property({ default: [] }) declare readonly data: number[];
@property({ default: 1 }) declare readonly progress: number;
protected renderSelf(ctx: RenderContext2D): void {
const data = this.data;
if (data.length === 0) return;
const w = 800, h = 400;
const max = Math.max(...data);
const slot = w / data.length;
const barWidth = slot * 0.6;
// Gridlines. Open outlines take one stroke each, see Painting.
for (let i = 0; i <= 4; i++) {
const y = -h / 2 + (h * i) / 4;
ctx.draw(
new Graphics2D()
.line({ points: [{ x: -w / 2, y }, { x: w / 2, y }] })
.stroke({ weight: 2, fill: 'white/25' }),
);
}
// Bars, as one family with a single gradient across all of them.
const bars = new Graphics2D();
data.forEach((value, i) => {
bars.rect({
bottomCenter: { x: -w / 2 + slot * (i + 0.5), y: -h / 2 },
width: barWidth,
height: (value / max) * h * this.progress,
cornerRadius: 8,
});
});
ctx.draw(bars.fill(Fills.linearGradient(['#6990DD', '#E8617C'])));
// Labels, in their own group so the gradient does not reach them.
const labels = new Graphics2D();
data.forEach((value, i) => {
labels.text({
text: String(value),
x: -w / 2 + slot * (i + 0.5),
y: -h / 2 - 36,
fontSize: 28,
});
});
ctx.draw(labels.fill('white/70'));
}
}
Hand BarChart to the engine, and a document grows the bars in with one command:
nodes: [
{ id: 'chart', type: 'barChart', parentId: null, layerOrder: 0,
props: { data: [3, 7, 5, 9, 4, 6], progress: 0, width: 900, height: 500 } },
],
commands: [
{ id: 'grow', type: 'to', targetId: 'chart', at: 0, duration: 1.4,
easing: { kind: 'out', args: 'quad' }, args: { progress: 1 } },
],
Shipping nodes as a package
A node is written against @motionscript/sdk whether it lives in your project or in a package of its own. A package of nodes takes the SDK as a peer dependency, so it shares the one copy of the engine the project installs: two copies in one module graph would leave the renderer unable to recognise the other copy's classes.
{
"peerDependencies": { "@motionscript/sdk": "^0.1.0-alpha.1" },
"devDependencies": { "@motionscript/sdk": "^0.1.0-alpha.1" }
}
Import from @motionscript/sdk, never from @motionscript/core, which is the engine and its host machinery as well as everything here. What a component package needs beyond that, a scene does not, so it sits on a subpath, @motionscript/sdk/component: entrances that can be seeked (at, sequence, together, hold, compose, drive, and asCommand to return one from a @command() method), declarePaints, number formatting and the orbit-camera lerps. A document's timeline evaluates a Command, so a method hands its composition to asCommand with the duration it was given.
import { Node2D, command, type Command, type CommandArgs } from '@motionscript/sdk';
import { asCommand, at, compose } from '@motionscript/sdk/component';
class Legend extends Node2D {
/** Fade the entries in one after another, across the whole slot. */
@command()
enter(args: CommandArgs<Record<string, never>> & { duration: number }): Command<Record<string, never>> {
const step = args.duration / (this.children.length + 1);
return asCommand(
compose(this.children.map((entry, i) => at(i * step, entry.to({ data: { opacity: 1 }, duration: 2 * step })))),
args.duration,
);
}
}
Export your classes as one array, so a host hands them to its engine as values rather than importing the package for its side effects:
export const NODES = [BarChart];
Next
- Properties for the knobs a node exposes, and how to make them animate.
- Declaring assets for images, fonts, data and sound your node loads.
- Graphics2D for the 2D drawing API.
- 3D Graphics2D for meshes, lights and cameras.