Skip to content

Theatre.js (TypeScript)

A sequencing/keyframe toolkit plus a GUI studio that animates the declared properties of any JavaScript object — a tweening-and-editor building block that emits interpolated values and delegates all rendering to its host, not a renderer of its own.

FieldValue
LanguageTypeScript (runs in the browser; also usable server-side/headless as a value source)
LicenseApache-2.0 for @theatre/core / @theatre/r3f; AGPL-3.0-only for @theatre/studio
Repositorytheatre-js/theatre (Yarn monorepo, ~12.5k stars; last pushed Aug 14 2024)
Documentationtheatrejs.com/docs/latest
CategoryKeyframe/sequence animation runtime + visual timeline editor (renderer-agnostic value layer)
First release@theatre/core 0.4.0-dev.1 (Jun 25 2021); first stable 0.4.0 (Sep 20 2021)
Latest release0.7.2 (May 19 2024) for @theatre/core, @theatre/studio, @theatre/r3f
What it rendersNothing. It emits interpolated prop values through object.onValuesChange(cb); the host (Three.js/WebGL, DOM/CSS, <canvas>) does every pixel
EditorTheatre Studio (@theatre/studio) — an in-browser visual keyframe/timeline editor, studio.initialize(), tree-shaken out of production

NOTE

This page is grounded in the official docs (theatrejs.com/docs/latest) and the source tree at theatre-js/theatre (main; package versions pinned to 0.7.2 on npm). Theatre.js produces no pixels of its own — it is a value-sequencing layer — 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 curves, Bézier bases, affine composition, readback checksum) that a host renderer driven by Theatre.js relies on — Theatre.js itself only touches the easing/lerp half.


Overview

What it solves

Theatre.js targets high-fidelity, hand-tuned motion design authored on a timeline rather than in imperative render code. Its own README states the scope plainly (README.md):

"Theatre.js is an animation library for high-fidelity motion graphics. It is designed to help you express detailed animation, enabling you to create intricate movement, and convey nuance."

The problem it owns is the one every code-only engine in this survey (Manim, Motion Canvas, Remotion) leaves to the author: placing and shaping keyframes by eye. Instead of computing an animation from a script, you declare the properties you want to drive, then scrub a playhead and set keyframes in a visual editor — Theatre Studio — while the library interpolates between them and pushes the results into your code every frame. It deliberately supplies only the timeline, the keyframes, the interpolation, and the GUI; it never decides what a shape is or how to draw one.

The complementary problem it solves is framework neutrality: the same sequencing core drives Three.js, the DOM, a <canvas>, or plain variables. The docs overview states (overview):

"Theatre.js works with any front-end library or framework."

Design philosophy

The project frames itself less as a library than as a design tool that erases role boundaries (overview):

"Theatre.js is a design tool in the making. We aim to blur the line between designer/developer, author/consumer, and artist/scientist."

Three commitments follow from that stance. First, the editor is a first-class, co-designed half — not a debugging aid but the intended authoring surface, which is why it ships as a distinct package under a copyleft (AGPL-3.0) license while the runtime is permissive (Apache-2.0). Second, the runtime is a pure value source: @theatre/core computes interpolated values from a saved keyframe state and hands them to you, with zero rendering assumptions, so it composes with any host. Third, animation state is data, not code — a project's keyframes serialize to a JSON blob that the editor exports and the runtime replays, which is what lets the editor be stripped from production entirely.


How it works

The core hierarchy is ProjectSheet → Sheet object. You get a project by name, carve it into sheets, declare a sheet object with a typed prop schema, and subscribe to its interpolated values. The canonical Three.js walkthrough (with THREE.js) shows every step.

A project is the top-level unit; getProject is idempotent by name and optionally seeded with an exported state JSON:

ts
import { getProject, types } from '@theatre/core';

// dev-only editor; tree-shaken out of production (see below)
import studio from '@theatre/studio';
studio.initialize();

const project = getProject('THREE.js x Theatre.js', { state: projectState });

A sheet groups objects that animate together and owns exactly one sequence (Sheets: "Sheets contain one or more Objects, that can be animated together."):

ts
const sheet = project.sheet('Animated scene');

A sheet object is a named bag of typed, animatable props declared with the types combinators — here a types.compound grouping three types.numbers, each with a display range:

ts
const torusKnotObj = sheet.object('Torus Knot', {
  rotation: types.compound({
    x: types.number(mesh.rotation.x, { range: [-2, 2] }),
    y: types.number(mesh.rotation.y, { range: [-2, 2] }),
    z: types.number(mesh.rotation.z, { range: [-2, 2] }),
  }),
});

Finally, object.onValuesChange(cb) is the entire hand-off to your renderer: Theatre.js interpolates the keyframed props and calls back with plain values, which you write into whatever host you like (with THREE.js):

ts
torusKnotObj.onValuesChange(values => {
  const { x, y, z } = values.rotation;
  mesh.rotation.set(x * Math.PI, y * Math.PI, z * Math.PI);
});

The split across two packages is the crux: @theatre/core is the runtime (getProject, types, onValuesChange, sequence playback); @theatre/studio is the editor, imported and initialize()d only in development and removed by the bundler for production (see Interactivity, preview & authoring). The reactive value machinery underneath both lives in a separate internal package, @theatre/dataverse (pointers + derivations), but consumers only ever touch the core/studio surface above.


Object & scene model

IMPORTANT

Theatre.js has no geometry scene graph, no Mobject, no vector paths. Its "objects" are not drawable shapes — they are named collections of typed scalar/compound props to animate. This is the first and biggest absence: where Manim's object model is the drawing (a retained tree of Béziers), Theatre.js's object model is a property schema over things it never sees.

The hierarchy is deliberately shallow and non-geometric:

LevelRoleAPI
ProjectTop-level container; holds the saved keyframe state. "All your work in Theatre.js is organized into Projects." (Projects)getProject(name, { state })
SheetGroups objects animated together; owns one sequence; supports instances (sheet(name, id))project.sheet(name, id?)
Sheet objectA named prop bag: "These Sheet Objects have a matching prop for all the properties we want to animate." (Objects)sheet.object(name, props)
propA leaf value with a type and an initial value (number, string, rgba, compound, …)types.number(0, opts) etc.

The objects page is explicit that an object stands in for something the host owns (Objects):

"Everything on the page or in the scene is represented by a Theatre.js Sheet Object. These Sheet Objects have a matching prop for all the properties we want to animate for an object in our scene."

An object can back "THREE.js objects, DOM elements, or virtual objects" — because Theatre.js never inspects the target, only its prop schema. Objects are addressed by namespaced keys (e.g. "Basics / Boxes / box-0"), and sheet instances (project.sheet('Button', 'Submit') vs ('Button', 'Cancel')) reuse one animation definition to drive many targets independently (Sheets). There is no parent/child transform tree, no submobject family, no affine model — spatial composition (nesting, transforms, camera) is entirely the host's job. The affine-transform.d probe's composition facts belong to whatever renderer consumes the values, not to Theatre.js.

Animation & timing model

This is Theatre.js's entire reason to exist, and the one axis where it is a full participant rather than a delegator. Its execution model is the survey's GUI-timeline / keyframe model (execution models): each Sheet owns a Sequence with a playheadsequence.position, a time in seconds — and animated props carry keyframes placed on that timeline. Playing the sequence sweeps the playhead; between adjacent keyframes the runtime interpolates (lerp) each prop and fires onValuesChange. Keyframes are authored in the editor, not in code (Sequences): "Removing keyframes is as easy as right-clicking on them and selecting the 'Delete' option."

The shape of the motion between two keyframes is a per-segment easing curve, edited with a tween editor that is CSS-like by design (Sequences):

"The tween editor can be used to apply timing functions that control the speed curve of the transition between the two keyframes. These are very similar to the transition-timing-functions in CSS."

Each segment is thus a cubic-Bézier timing function — the same rate-function / easing reshaping the rate-functions.d probe tabulates, applied per keyframe pair rather than per whole animation. This is a sharper contrast with Manim than it first appears: Manim wraps a whole play() call in one rate_func and builds larger animations from a combinator algebra (AnimationGroup, Succession, lag_ratio; see animation composition); Theatre.js has no such combinators — "composition" is just where you drop keyframes on a shared timeline and which easing curve you draw between them. Staggering that Manim expresses as lag_ratio is expressed here as offset keyframe positions on the track.

Playback is driven with sheet.sequence.play(conf), whose conf mirrors CSS animation controls — rate (speed multiplier, default 1), range ({ from, to } in the timeline), iterationCount (default 1, may be Infinity), and direction ('normal' | 'reverse' | 'alternate' | 'alternateReverse'); the call returns a promise that resolves true on natural completion or false if interrupted by sequence.pause() (Sequences):

ts
sheet.sequence.play({ iterationCount: Infinity, direction: 'alternate' });

Because sequence.position is settable, you can also scrub programmatically (bind it to scroll, a slider, or audio time) — the runtime recomputes all prop values from the new playhead with no notion of "current frame index." There is no ValueTracker/updater machinery to bolt on: driving a value is the whole model, and onValuesChange is the reactive push. What Manim reaches for with add_updater and always_redraw, Theatre.js gets structurally from the sequence → onValuesChange pipeline.

Rendering backend & rasterization

WARNING

Theatre.js has no rendering backend and no rasterizer — this axis is entirely an absence. It never fills a path, strokes a line, allocates a framebuffer, or touches a GPU. There is no Cairo/CPU-vector oracle and no GPU stencil-and-cover path because there is no drawing at all.

Everything the survey files under rasterization happens in the host. When onValuesChange hands you { x, y, z }, you call mesh.rotation.set(...) and Three.js/WebGL rasterizes; or you set div.style.left and the browser composites; or you paint a <canvas> and the Canvas2D implementation rasterizes. Theatre.js is agnostic to all of it, which is precisely the "works with any front-end library or framework" (overview) promise.

The color model concern is likewise the host's. Theatre.js offers a types.rgba prop that it will interpolate as four components, but it has no opinion on premultiplied alpha, sRGB vs linear space, or gamma-correct compositing — it lerps the channel numbers and the consumer decides how to interpret and blend them. Whether that blend is gamma-correct (the color-model-and-gamma pitfall) is determined wholly by the renderer the values flow into. Anti-aliasing, MSAA, and driver reproducibility (CPU-vs-GPU) are simply out of scope.

Typesetting & text

IMPORTANT

No typesetting, no text layout, no glyph handling — another clean absence. Theatre.js has no glyph-outline extraction, no LaTeX-to-SVG pipeline, no HarfBuzz/Pango shaping, and no font model of any kind.

A types.string prop exists, but it is an animatable string value, not typeset text: Theatre.js will keyframe and interpolate it as a value and hand it back via onValuesChange. What that string becomes — a DOM text node the browser shapes with HarfBuzz, a Three.js text mesh, a <canvas>fillText call, a LaTeX-compiled equation — is entirely the host's doing. There is nothing analogous to Manim's dvisvgm route or Motion Canvas's in-process MathJax Latex node. If you want animated math or morphable glyph contours, you render them in the host and animate the host's parameters (opacity, position, progress) through a Theatre.js object. The glyph-outline and text-shaping concepts have no representation in this engine.

Output & encoding

WARNING

Theatre.js is not a video tool. It has no encoder, no frame-capture path, no muxer, and no headless render-to-file mode. The frame-capture / readback and codec / muxing / pixel-format concerns are absent — there is nothing to read back because Theatre.js renders nothing.

Its only "output" is project state as JSON — the saved keyframes, not pixels. The editor exports it and the runtime replays it (Projects):

"This state is stored as a JSON object in localStorage when the studio is open and can be exported as a JSON file."

ts
import projectState from './state.json';
const project = getProject('My Project', { state: projectState });

Producing a video is therefore a host-and-toolchain problem entirely outside Theatre.js. To capture frames deterministically you would drive sequence.position in a headless host (e.g. a WebGL context under a browser automation harness), read that host back (readback), and pipe the frames to an external encoder (codec/muxing) — none of which Theatre.js provides, recommends, or ships. Where Manim community pipes RGBA to ffmpeg/PyAV and Motion Canvas has an @motion-canvas/ffmpeg exporter, Theatre.js has no export pipeline at all: it is a live/runtime animation driver, not a batch renderer.

Interactivity, preview & authoring

This is the axis where Theatre.js is strongest — the timeline editor is the product. Theatre Studio is the in-browser visual authoring surface (Studio):

"The Studio is Theatre.js' editor that you can use at development to edit your scene, tweak values and create animations."

It renders only when explicitly started — "The Studio is only shown if studio.initialize is called" (Studio) — and provides a scrubbable timeline with a playhead, per-object keyframe tracks, a details panel for nudging live prop values (bounded by each prop's range guide), and the tween editor for drawing per-segment cubic-Bézier easing curves. Real-time editing is the whole point: drag a value, set a keyframe, move the playhead, watch the host update through onValuesChange.

The signature production concern is that the editor is excluded from shipped builds. @theatre/studio is imported and initialized behind an environment guard so the bundler tree-shakes it away (with React Three Fiber):

ts
// Vite
if (import.meta.env.DEV) {
  studio.initialize();
  studio.extend(extension);
}
// Create-React-App
if (process.env.NODE_ENV === 'development') {
  studio.initialize();
  studio.extend(extension);
}

The docs make the tree-shaking explicit (with React Three Fiber):

"We can also achieve the last step without manually editing the code every time by using environment-checks and relying on our bundler's tree-shaking feature."

Going to production is then just: "1. Make sure that we have the latest project state exported to a JSON file and passed to getProject. 2. Remove studio.initialize and studio.extend." (with React Three Fiber). The AGPL-licensed editor thus never reaches end users; only the Apache-licensed runtime and a JSON state file do.

Extensibility & API surface

The surface is small and cleanly two-layered:

  • Runtime vs editor split@theatre/core (getProject/sheet/object/types/sequence/onValuesChange) carries all playback and value logic with no editor and no rendering; @theatre/studio is the optional, dev-only authoring UI. The reactive value core is factored out again into @theatre/dataverse.
  • Prop-type system — the types combinators are the extension point for what can be animated: types.number(v, { range, nudgeMultiplier }), types.string, types.boolean, types.stringLiteral (enumerated choices), types.rgba (color), types.image (asset refs, since 0.6.0), and types.compound({...}) to group props into a nested structure. The docs note a number's range is presentational — "opts.range is just a visual guide, not a validation rule" (Prop types).
  • studio.extend(extension) — custom editor panes/toolbars plug into the Studio via an extension API (used above alongside studio.initialize()).
  • Integrations@theatre/r3f is the marquee one: "A Theatre.js extension for THREE.js with React Three Fiber" (r3f README). It exposes an editable as e HOC (<e.mesh theatreKey="..." />) and a PerspectiveCamera with makeDefault, so R3F objects become Theatre.js objects declaratively. @theatre/theatric offers a hook-first React API over the same core.
  • Host-agnostic by construction — because the only contract is "declare typed props, receive interpolated values," any target (WebGL, DOM/CSS, <canvas>, audio params, plain variables) is drivable with no adapter beyond an onValuesChange handler.

Determinism, caching & performance

Theatre.js is deterministic in the strong sense the survey wants: the mapping from sequence.position (the playhead) and the saved keyframe state to the prop values delivered by onValuesChange is a pure function of the playhead — seek to the same position and you get identical values every run. This satisfies the deterministic-frame-sampling prerequisite for the value layer. But the survey's determinism concern is ultimately about pixels, and Theatre.js emits none; whether the final frames are bit-reproducible is entirely a property of the host renderer (a Canvas2D/WebGL host is not bit-identical across drivers; a CPU-vector host could be). Theatre.js removes one source of nondeterminism (the animation math) and delegates the rest.

NOTE

No content-hash / partial-movie caching — because there is no render to cache. Manim community hashes each play() call and reuses a cached partial movie file; that optimization is meaningless here, since Theatre.js never produces a movie. Its "cache" is the JSON state file (authoring output reused as runtime input) plus the internal @theatre/dataverse derivation graph, which memoizes computed values until a dependency changes — a within-frame compute cache, not a cross-run render cache. The frame-capture.d checksum a render cache would key on has no analog on the Theatre.js side of the boundary.

Performance-wise the runtime is intentionally light: it maintains a reactive pointer/derivation graph and fires callbacks, so cost scales with the number of animated props and subscribers, not with any drawing. The heavy work — geometry, rasterization, encoding — is always the host's, so Theatre.js's own footprint in a production bundle is just the Apache-licensed @theatre/core runtime plus the state JSON.


Strengths

  • Best-in-class visual keyframe authoring. Theatre Studio's scrubbable timeline, per-segment cubic-Bézier easing tween editor, and live value nudging make hand-tuned motion far easier than any code-only engine in this survey.
  • Truly renderer-agnostic. The "declare typed props → receive interpolated values" contract drives Three.js, the DOM, <canvas>, audio, or plain variables with no rendering assumptions — "works with any front-end library or framework."
  • Animation-as-data. Keyframes serialize to JSON; the editor exports it and the runtime replays it, giving a clean dev-authoring / prod-replay split.
  • Editor is free in production. The dev-only @theatre/studio is tree-shaken out; production ships only the small Apache-licensed runtime and a state file.
  • Rich, extensible prop-type system (number with range/nudgeMultiplier, compound, rgba, stringLiteral, image, …) plus studio.extend and first-party integrations (@theatre/r3f, @theatre/theatric).
  • Deterministic value layer — playhead → values is a pure function, removing the animation math as a source of nondeterminism.

Weaknesses

  • Renders nothing. No rasterizer, no geometry, no scene graph — you must bring a host renderer and wire every prop through onValuesChange yourself.
  • No object/geometry model. Objects are prop bags, not shapes; there is no Mobject/VMobject equivalent, no transforms, no morphing of paths — spatial structure is 100% the host's.
  • No typesetting or text. No glyph-outline extraction, no LaTeX, no shaping; types.string is a value, not typeset text.
  • No video output. No frame capture/readback, no encoder/muxer; exporting a movie is an external toolchain problem Theatre.js does not address.
  • No composition combinator algebra. No AnimationGroup/Succession/ lag_ratio (composition); sequencing is manual keyframe placement on a timeline.
  • Editor is AGPL-3.0. @theatre/studio is copyleft; teams that vendor or modify the editor must weigh its terms (the runtime is permissive, so shipped apps are unaffected).
  • Pre-1.0 and quiet. Latest 0.7.2 (May 2024); the repo's last push is Aug 2024 — API stability and momentum are open questions.

Key design decisions and trade-offs

DecisionRationaleTrade-off
Value-only core; delegate all rendering to the hostWorks with any framework; keeps the runtime tiny and unopinionatedNo object/geometry/text/pixel model — the developer must supply and wire a renderer
Project → Sheet → object of typed props, not shapesA minimal, serializable schema over things Theatre.js never inspectsNo scene graph, transforms, or morphing; spatial composition is the host's job
GUI-timeline / keyframe execution with a scrubbable playheadHand-tuned motion design; author by eye, scrub sequence.position at runtimeNo code-level combinator algebra (lag_ratio, Succession); staggering is manual
Per-segment cubic-Bézier easing (CSS-like tween editor)Fine-grained, visually-editable speed curves between keyframesEasing lives in editor state, not in source; reviewability differs from code
Split runtime (Apache-2.0) vs editor (AGPL-3.0) packagesEditor is a first-class product; permissive runtime for shipped appsCopyleft editor complicates vendoring/forking Studio; two licenses to reason about
Editor excluded from production via env-guard tree-shakingZero editor weight in prod; authoring output reduced to a JSON state fileRequires bundler discipline; forgetting the guard ships (and exposes) the AGPL editor
Animation state as exported JSON, replayed by getProjectClean dev-authoring / prod-replay boundary; state is diffable dataNot human-authored; hand-editing keyframe JSON is impractical vs code-defined motion
onValuesChange push as the sole host hand-offOne uniform, framework-neutral integration pointEvery target property needs a hand-written subscriber; no automatic binding

Sources

Primary sources (docs pinned to the live latest site unless noted; source and licenses from theatre-js/theatre main; versions from npm 0.7.2):