Motion Canvas (TypeScript)
A TypeScript library plus a real-time editor that programs 2D vector animations as generator functions driven against a moving play-head, rendering through the browser's HTML5 Canvas2D context and reacting to change through signals.
| Field | Value |
|---|---|
| Language | TypeScript (runs in the browser; tooling on Node.js ≥ 16) |
| License | MIT (Copyright (c) 2022 motion-canvas) |
| Repository | motion-canvas/motion-canvas (Lerna monorepo, ~18.8k stars) |
| Documentation | motioncanvas.io/docs |
| Category | Code-driven 2D vector motion-graphics engine (web / retained node tree) |
| First release | @motion-canvas/core v2.0.0 (Feb 4 2023); repo public since Aug 2022 |
| Latest release | v3.17.2 (Dec 14 2024); v3.18.0-alpha.0 in progress; repo last pushed Jul 2026 |
| Rendering surface | HTML5 Canvas2D (CanvasRenderingContext2D) per node; a shared WebGL2 context for shader passes only |
| Timing model | Generator / play-head: scenes are function*, animations are generators consumed with yield* |
| Editor | A Vite plugin (@motion-canvas/vite-plugin + @motion-canvas/ui) serving a live-preview editor with a scrubbable timeline |
NOTE
This page is grounded in the official docs (motioncanvas.io/docs) and the source tree pinned at tag v3.17.2. Prose behaviour is quoted from the packages/docs MDX; runtime mechanics are quoted from packages/core and packages/2d. Motion Canvas renders in a browser, so no ci-compiled D probe reproduces its output; the catalog's dependency-free probes (rate-functions.d, bezier-eval.d, affine-transform.d, frame-capture.d) reimplement the shared math (easing, Bézier bases, affine composition, readback checksum) that this engine also relies on.
Overview
What it solves
Motion Canvas targets informative, voice-over-synchronised vector animations — the explainer-video niche — authored entirely in code but previewed and fine-tuned in a live editor. It is deliberately not a general video editor. The introduction states its scope plainly:
"It's a specialized tool designed to create informative vector animations and synchronize them with voice-overs. It's not meant to be a replacement for traditional video editing software."
Two problems drive its design. First, expressing time in code without the imperative self.play(...) ceremony of Manim or the frame-indexed purity of Remotion: Motion Canvas borrows JavaScript generators so an animation reads top-to-bottom as a script, pausing at each yield. Second, audio sync: the single hardest part of an explainer is landing a beat on a spoken cue, which the editor solves with draggable time events rather than hard-coded frame counts.
Design philosophy
The project describes itself as two things:
"Motion Canvas consists of two main components:- A TypeScript library that uses generators to program animations.- An editor providing a real-time preview of said animations."
The library and editor are co-designed: the same generator that is the animation is also what the editor steps, scrubs, and instruments. The value layer underneath is reactive — every node property is a signal, so a change to one value lazily propagates to everything derived from it, and a tween is just a signal being driven over time. The node tree is retained (a DOM-like hierarchy the engine re-renders each frame; see retained vs immediate mode) but authored with a custom JSX runtime that is not React — JSX tags map straight to Node instances with no virtual DOM (hierarchy):
"Note that Motion Canvas does not use React itself, only JSX. There's no virtual DOM or reconciliation and the JSX tags are mapped directly to Node instances."
How it works
A project (makeProject) is an ordered list of scenes; each scene is a generator produced by makeScene2D(function* (view) { … }). The view argument is the root Node of a retained tree. You build the tree with JSX, grab handles with createRef, then yield* animation generators against the scene's play-head. The canonical quickstart scene:
import { makeScene2D, Circle } from '@motion-canvas/2d';
import { all, createRef } from '@motion-canvas/core';
export default makeScene2D(function* (view) {
const myCircle = createRef<Circle>();
view.add(
<Circle ref={myCircle} x={-300} width={140} height={140} fill="#e13238" />,
);
yield* all(
myCircle().position.x(300, 1).to(-300, 1), // animate x over 1s, then back
myCircle().fill('#e6a700', 1).to('#e13238', 1),
);
});Three call shapes on a single property signal — myCircle().fill() reads, myCircle().fill('#e6a700') sets, myCircle().fill('#e6a700', 1) returns a tween generator — are the whole surface. yield* delegates the scene generator to that tween until it completes; .to(value, dur) chains a second tween onto the first; all(...) merges generators so they advance together. The editor watches the source via Vite and hot-reloads the preview on save.
The frame the reader sees is produced by the Stage, which owns three HTMLCanvasElement buffers and their 2D contexts; every Node draws itself into that context (see rendering).
Object & scene model
Motion Canvas uses a retained tree of nodes rooted at the scene view, explicitly likened to the DOM (hierarchy): "They're organized in a tree hierarchy, with the scene view at its root. This concept is similar to the Document Object Model." This is the survey's Mobject / scene-graph role, filled by a class hierarchy rather than one Mobject type:
| Class | Role |
|---|---|
Node | Base: transform, opacity, parent/children, render, caching, querying |
Layout | A Node with size/offset and Flexbox participation |
Shape | A Layout with fill/stroke/lineWidth — base of the drawable primitives |
Rect, Circle | Filled/stroked primitives (Flexbox-aware) |
Txt / TxtLeaf | Text runs (Canvas text; see typesetting) |
Line, Path, Ray, Spline, Bezier, Curve, Polygon, Knot | Vector-path geometry (piecewise Bézier; the VMobject analogue) |
Img, Video, SVG, Latex, Code | Raster/vector/text-derived content nodes |
Camera, Grid, Icon | Framing and helpers |
JSX is sugar over constructors — the two are documented as equivalent (hierarchy):
// JSX form … // … equals the imperative form
view.add(
<Layout>
<Rect />
<Txt>Hi</Txt>
</Layout>,
);
view.add(new Layout({ children: [new Rect({}), new Txt({ text: 'Hi' })] }));The tree is mutable at any time (add, insert, remove, reparent, moveTo/moveUp/moveToTop, …) and queryable: view.findAll(is(Txt)) takes a predicate and walks descendants; is(T) builds an instanceof predicate. Positioning is a Cartesian system with the origin at the scene centre, x right, y down (positioning); each node exposes local position/scale/rotation, world-space absolutePosition/absoluteRotation, and the underlying affine matrices (localToWorld, worldToParent, …) for mapping between spaces — the composition-order and non-commutativity facts the affine-transform.d probe verifies.
Animation & timing model
This is Motion Canvas's defining axis. Time is a generator / play-head (execution models): a scene is a function*, and yield means "this frame is ready." The flow page names it the core idea:
"This is the fundamental idea of Motion Canvas.
yieldmeans: 'The current frame is ready, display it on the screen and come back to me later.'"
A bare yield emits one frame; yield* delegates to a sub-generator until it finishes. An animation — a tween, a flow combinator, a hand-written function* — is just a generator, so composition is ordinary generator delegation. This contrasts sharply with the other engines in this survey: Manim's imperative self.play() steps an external scene loop; Remotion makes a frame a pure function of its index; Motion Canvas instead suspends a coroutine at each frame boundary. (The catalog's execution-models glossary lays the three side by side.)
Tweens are the leaf animations (tweening): tween(dur, t => …) calls the callback each frame with t ∈ [0,1], or the property-signal shorthand node.prop(target, dur, timingFn?, interpolationFn?) tweens from the current value to target. Timing (easing) functions map [0,1] → [0,1]; the library ships the standard easings.net set (easeInOutCubic is the property-tween default) and any JS function qualifies — the reshaping the rate-functions.d probe tabulates for the rate-function axis. Interpolation of non-scalars goes through a static lerp on each complex type (Color.lerp, Vector2.lerp, Vector2.arcLerp for a curved path) — the lerp the survey defines. A separate spring(desc, from, to, settleTolerance?) integrates a physical spring:
"The
springfunction allows us to interpolate between two values using Hooke's law." — tweening
Flow generators (flow) are the composition combinators over the [0,1] timeline:
| Combinator | Meaning |
|---|---|
all(...gens) | run all in parallel; finishes with the longest |
any(...gens) | run in parallel; finishes with the shortest |
chain(...gens) | run sequentially, one after another |
sequence(delay, …) | stagger starts by a fixed delay (the lag_ratio analogue) |
loop(n, i => gen) | repeat a generator |
delay(time, gen) | wait, then run |
waitFor(seconds) | advance the play-head by a fixed duration |
waitUntil('event') | pause until a named, editor-editable time event |
Because properties are signals, animation is reactive rather than imperative bookkeeping. A signal is a value that may change over time (signals):
"Signals represent a value that may change over time. They can be used to define dependencies between the state of the animation. This way, when a value changes, all other values that depend on it get automatically updated."
createSignal(0) makes a scalar signal; passing a function makes a computed signal that tracks its dependencies, recomputes lazily, and caches the result:
const radius = createSignal(1);
const area = createSignal(() => Math.PI * radius() * radius());
area(); // computes: 3.14159…
radius(2);
area(); // recomputes once, lazily: 12.566…Every node property is a signal, so a scene can be made data-driven — animate radius and everything derived from it follows, no per-frame updater needed. This subsumes the imperative engines' updater/ValueTracker machinery: where Manim adds an add_updater(fn) callback and wraps a scalar in a ValueTracker, Motion Canvas gets the same reactivity for free from the signal graph. Signals also carry a DEFAULT sentinel to reset (or tween back) to their initial/inherited value, and node save()/restore() snapshot state onto a stack.
Rendering backend & rasterization
The rendering surface is the browser's HTML5 Canvas2D. Every node's render method takes a CanvasRenderingContext2D (Node.ts):
public render(context: CanvasRenderingContext2D) { … } // Node.ts:1649
protected draw(context: CanvasRenderingContext2D) { … } // Node.ts:1690The single 2D-context factory confirms the surface (getContext.ts):
"
const context = canvas.getContext('2d', options);"
render saves the context, applies the node's localToParent matrix via context.transform(a,b,c,d,e,f) (a DOMMatrix), draws itself or its cache, then recurses into drawChildren — an ordinary CPU-vector retained rasterisation pass. The Stage owns the output as plain HTMLCanvasElement buffers (finalBuffer, plus current/previous for scene transitions), and its context is created willReadFrequently because frames are read back for export. Colour space is a first-class knob — private colorSpace: CanvasColorSpace = 'srgb' by default, with DCI-P3 wide-gamut selectable (rendering) — the browser's canvas handles the gamma/linear compositing.
NOTE
WebGL is a secondary, opt-in path — not the base renderer. A single SharedWebGLContext (a WebGL2RenderingContext borrowed by one owner at a time) exists only to run fragment-shader effects: when a node declares a ShaderConfig, render routes its cached canvas through shaderCanvas(...) before compositing back into the 2D context. The geometry, fills, strokes, text, and the frame you export are all Canvas2D. There is no GPU-vector stencil-and-cover path — Motion Canvas leans on the browser's own path rasteriser, so anti-aliasing is whatever the Canvas2D implementation provides.
Expensive subtrees are cached: requiresCache() (opacity groups, filters, shaders, compositing) diverts a node to an offscreen cachedCanvas() that is re-blitted with drawImage until invalidated — a node-level cache, distinct from the frame-level content-hash caching question below.
Typesetting & text
Two distinct paths, and neither matches Manim's LaTeX-to-dvisvgm pipeline:
Plain text (
Txt/TxtLeaf) is drawn directly by the Canvas2D text engine.TxtLeaf.drawsetscontext.font,context.letterSpacing, and measures withcontext.measureText(...), then paints withcontext.fillText/context.strokeText(TxtLeaf.ts). Consequently shaping is delegated to the browser (HarfBuzz inside Chromium) rather than an in-process HarfBuzz/Pango stack, and there is no glyph-outline extraction for ordinary text — glyphs are rasterised by the canvas, not converted to VMobject paths. The trade-off is that plainTxtcannot be morphed at the contour level the way an outline-extracted glyph can.Math (
Latex) compiles TeX to SVG in-process with MathJax — no TeX installation, nodvisvgm.Latex.tsimportsmathjax-fulland configures anew SVG(...)output jax (Latex.ts):tsimport { TeX } from 'mathjax-full/js/input/tex'; import { SVG } from 'mathjax-full/js/output/svg'; // Latex.ts:14,17The emitted SVG is parsed into an
SVGnode whose paths are vector geometry, so an equation can be tweened contour-to-contour — and a doubled-brace group syntax in thetexstring lets you diff two formulas for a morph. This is the survey's glyph-outline / LaTeX-to-SVG concept reached through a self-contained JS compiler instead of a system TeX +dvisvgmtoolchain.Code(via a companion highlighter) covers animated source-code typesetting.
Output & encoding
Rendering is driven from the editor's Video Settings tab: pick a frame Range, Resolution, Frame rate, Scale, and colour space, press RENDER, and Motion Canvas "plays through the animation" writing frames to /output (rendering). The render loop seeks the playback to each integer frame and reads the Stage back — an export via frame capture / readback.
The default, built-in exporter is an image sequence, and readback is the browser's own toDataURL (ImageExporter.ts):
data: canvas.toDataURL(this.fileType, this.quality), // ImageExporter.ts:113fileType is image/png (lossless), image/jpeg, or image/webp (browser-gated — "may not work on Safari"), quality 0–100 (image sequence). The PNG frames drop into a video editor for muxing.
Direct video is a separate, optional package, @motion-canvas/ffmpeg, which plugs into vite.config.ts and shells out to FFmpeg to mux the frame stream (video (FFmpeg)) — the codec / muxing / pixel-format step. Because the renderer lives in the browser but FFmpeg cannot, the exporter runs FFmpeg on the Node side of the Vite dev server and streams frames to it; "You do not need to install FFmpeg yourself. It will be installed automatically." Still frames can be grabbed one at a time to /output/still.
NOTE
There is no first-party in-process libav integration à la Manim community's PyAV: the split is deliberately browser (render) → Node (mux). Reproducible encodes therefore hinge on pinning the bundled FFmpeg build.
Interactivity, preview & authoring
The editor is the differentiator. It is a Vite plugin (@motion-canvas/vite-plugin) mounting the @motion-canvas/ui app; npm start serves it at http://localhost:9000/, and any source edit hot-reloads the preview (the ?scene import transform enables per-scene refresh). It provides a scrubbable timeline with a play-head, a playback-range selector, live inspection (node overlays via drawOverlay), and separate preview-vs-render Scale/Frame rate so the preview can run cheap while the export runs at full quality (rendering).
The signature authoring feature is time events for audio sync (time events). Instead of guessing a duration, you write a named pause:
yield * animationOne();
yield * waitUntil('event'); // no hard-coded seconds
yield * animationTwo();The named event then appears in the editor as a draggable pill; its timing is edited by hand in the UI, not in code, and adjusting one event shifts all later events (hold SHIFT to pin). useDuration('event') reads an event's editor-set length back into the animation (circle().scale(2, useDuration('event'))). A player package ships a custom element to embed finished animations in any web page, and a presentation mode turns a project into a slide deck.
Extensibility & API surface
Extensibility is layered:
- Custom components — subclass
Node,Shape, orLayoutand implementdraw(context); the@decorator-based@signal/@computed/@initialproperty system (used throughoutpackages/2d) makes new animatable props first-class signals. - Renderer-agnostic core —
@motion-canvas/coreholds all timing, signals, flow, and playback logic with no 2D assumptions; the README calls@motion-canvas/2d"The default renderer for 2D motion graphics," implying core can back alternative renderers. - Custom exporters — implement the
Exporterinterface (create,start,handleFrame,stop); the FFmpeg exporter is exactly this, plugged in as a Vite plugin. Exporter choice is a project-config field. - Shaders — a
ShaderConfigattaches a GLSL fragment shader to any node, compiled through theSharedWebGLContext. - Interpolation / easing — any
(t) => numberis a valid timing function and any(a, b, t) => Ta valid interpolation function; complex types expose a staticlerpyou can extend. - Vite ecosystem — the whole toolchain is Vite plugins, so bundling, TypeScript, and asset handling come from the standard web build stack.
Determinism, caching & performance
Frame output is deterministic as a function of the frame index: the renderer reloads scenes and seeks the playback to an integer frame, then reads the Stage — the same code yields the same frame sequence, satisfying the survey's deterministic-sampling prerequisite for reproducible export.
WARNING
Determinism is per-machine, not bit-identical across machines. The pixels come from the browser's Canvas2D rasteriser (and, for shader nodes, its WebGL driver), so anti-aliasing and blending can differ across browsers, OSes, and GPUs — the same CPU-vector-vs-GPU reproducibility caveat the survey raises. Motion Canvas has no CPU "oracle" backend to fall back on; the Canvas2D output is the render.
NOTE
No Manim-style frame/content-hash caching — an absence worth stating. Manim community hashes each play() call and reuses a cached partial movie file (content-hash caching); Motion Canvas has no such layer — it re-plays and re-renders the whole selected range on every export. Its caching is confined to the node level: requiresCache() diverts expensive subtrees (opacity groups, filters, shaders) to an offscreen cachedCanvas() that is invalidated through the signal graph and otherwise re-blitted. The iteration-speed win comes instead from the live editor (instant scrubbing, hot-reload) rather than from cross-run render caching.
The signal layer doubles as a compute cache within a frame: computed signals memoise until a dependency changes, and the docs note the engine "uses signals internally to cache things such as matrices" (signals). Performance is otherwise bounded by single-threaded Canvas2D in the browser, mitigated by lower-resolution/frame-rate previews.
Strengths
- Generators make time read like a script.
yield* anim()sequences,all/any/chain/loopcompose, and the whole animation is one linear function — no external play-loop, no frame-index arithmetic. - Signals give reactivity for free. Every property is a lazy, cached, dependency-tracked signal, so data-driven scenes need no manual updaters or
ValueTrackerequivalents. - A genuinely good editor. Live preview, timeline scrubbing, and draggable, code-referenced time events solve audio sync — the actual pain point of explainer videos — better than any code-only engine here.
- Self-contained math typesetting.
Latexvia MathJax needs no TeX install ordvisvgm, and emits animatable SVG paths. - Web-native and hackable. TypeScript, JSX, Flexbox layout, the Vite plugin ecosystem, embeddable
playerelement, MIT-licensed. - Flexbox layout for structured diagrams — a real constraint-ish layout system most animation engines lack.
Weaknesses
- Browser-bound rendering. Output is Canvas2D in a browser; there is no headless CPU oracle, so frames are not bit-reproducible across machines, and heavy scenes are single-threaded.
- No cross-run render caching. Unlike Manim's partial-movie-file cache, every export re-renders the whole range; fast iteration relies on the editor, not the batch renderer.
- Plain text is not vector geometry.
Txtis rasterised by the canvas (no glyph-outline extraction), so ordinary text cannot be morphed at the contour level; only theLatex/SVGpath yields tweenable outlines. - Video export is a bolt-on. FFmpeg muxing is a separate package running on the Node side; the built-in exporter only emits an image sequence.
- Narrow by design. Explicitly "not meant to be a replacement for traditional video editing software" — 2D only, explainer-shaped.
- Generators have a learning curve.
yieldvsyield*, and "no*here" footguns infor-loops, trip newcomers (the flow page spends real ink on it).
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Generators as the timing model | Animation reads as a linear script; yield* composes without a play-loop | Generator semantics (yield vs yield*) are a learning curve |
| Signals for every property | Lazy, cached, dependency-tracked reactivity; data-driven scenes for free | A whole reactive graph to reason about; debugging propagation is subtler than setters |
| Custom JSX runtime, not React | Familiar markup, direct Node instances, no virtual-DOM/reconciliation cost | Bespoke runtime to maintain; not a drop-in for the React ecosystem |
| HTML5 Canvas2D as the render surface | Ubiquitous, simple retained 2D drawing; live browser preview | Not bit-reproducible across machines; single-threaded; no GPU-vector fast path |
| WebGL only for shader effects | GPU where it pays (post-effects) without a full GPU renderer | Effects layer bolted onto a CPU compositor; extra context to manage |
Editor-editable time events (waitUntil) | Solves voice-over sync without hard-coded durations | Timing now lives partly outside source control, in editor metadata |
| MathJax (in-process) for LaTeX | No TeX/dvisvgm dependency; portable, animatable SVG output | Ships a large JS math engine; MathJax coverage, not full LaTeX |
| Image-sequence default, FFmpeg as a plugin | Zero-dependency built-in export; muxing opt-in | Direct video needs an extra package and a Node-side FFmpeg process |
| Node-level cache, no frame-hash cache | Reactive invalidation is simpler than hashing play() calls | Every export re-renders the full range; no Manim-style partial-movie reuse |
Sources
Primary sources (docs pinned to the live site; source pinned to tag v3.17.2):
- Introduction /
intro.md— the "two components," vector-animation-plus-voice-over scope, and the "not a video editor" framing. - Animation flow /
flow.mdx— generators,yield/yield*, and theall/any/chain/sequence/loop/delay/waitFor/waitUntilcombinators. - Signals —
createSignal, computed signals, laziness, caching, dependency tracking,DEFAULT. - Tweening —
tween, property tweens, timing/interpolation functions,Color.lerp/Vector2.arcLerp,spring(Hooke's law),save/restore. - Scene hierarchy — DOM-like retained tree, JSX-not-React,
add/insert/reparent,findAll(is(T)). - Layouts — Flexbox participation, layout roots, cardinal directions.
- Positioning — Cartesian space, world-space helpers, matrices.
- Time events —
waitUntil, editor-draggable events,useDuration. - Rendering · Image sequence · Video (FFmpeg) — Video Settings, colour space, exporters.
Node.ts·getContext.ts·Stage.ts— Canvas2Drender(context), thegetContext('2d')factory, theStagebuffers.SharedWebGLContext.ts— the WebGL2 shader-only context.ImageExporter.ts—canvas.toDataURLframe readback.Latex.ts·TxtLeaf.ts— MathJax→SVG math; CanvasfillTextplain text.README.md— monorepo package list; "default renderer for 2D."LICENSE— MIT.