| name | animation |
| description | Author animated technical explainer diagrams as .anim.json files for Nimbalyst's Animation editor. Use when the user wants to animate a diagram, show how a system/protocol/algorithm behaves over time, build a motion explainer, or turn a static architecture diagram into something that plays. |
Animation - step-based explainer diagrams
.anim.json files open in Nimbalyst's Animation editor: a named scene plus an ordered list of steps that assign states to the scene's parts. You write plain JSON. Any agent can author or edit one with Write and Edit -- there is no binary format and no tool call required.
When to use this
- Explaining how a system behaves over time: a request crossing a network, a consensus round, a cache filling, a build pipeline, a queue draining.
- Turning a static architecture diagram into something that plays.
- Showing a failure and a recovery: the retry, the rollback, the rejected review.
Do not use it when a static diagram says the same thing. If nothing changes between the first frame and the last, you want Excalidraw or a Mermaid block, not an animation.
The mental model
Three rules drive every decision in this format:
- The document says what is true when, never how to tween. There are no keyframes, easing curves, or property tracks. You assign a state to a part, and CSS transitions interpolate. Adding motion means naming a state, not scripting a timeline.
- States are cumulative. A step asserts only what changes; every part it does not mention keeps whatever the previous step left it in. Write deltas. To turn something off you must explicitly set it back -- it will not decay on its own.
- Ids are names, not handles.
store, title-card, queueTask01. You will reference them constantly in set blocks; make them readable.
Times are integer milliseconds. Never frame indices, never floats.
Document structure
{
"version": 1,
"stage": { "width": 1200, "height": 640, "fps": 25 },
"parts": { "<id>": { "type": "node" | "edge" | "label" | "shape", ... } },
"steps": [ { "id": "...", "duration": 800, "caption": "...", "set": { ... } } ]
}
stage
| Field | Notes |
|---|
width, height | Clamped to 16..8192. The stage scales to fit the pane, so these set the aspect ratio and the coordinate system, not the pixel size. |
fps | Only affects frame snapping and the readout. Use 25 unless you have a reason. Whole-millisecond frame rates: 10, 20, 25, 50. |
background | Optional override. Omit it and the stage uses the theme surface, which is what you want -- the scene then follows the user's light/dark theme. |
1200x640 is a good default. Landscape, room for a header row and a bottom rail.
parts
Part ids are the keys. All four types share label, tone, and state (their baseline, before any step runs).
node -- the workhorse. A titled card with an optional subtitle and key/value rows.
{ "type": "node", "label": "Object store", "x": 740, "y": 118, "w": 240, "h": 176,
"subtitle": "SHA -> BYTES",
"rows": [ { "key": "f7a9", "value": "commit 182 B" }, { "key": "e816" } ] }
label is uppercased automatically. Write "Merge gate", it renders MERGE GATE. Falls back to the id.
subtitle is a small mono line under the header. Keep it short and caps-ish; it is where model names, worktrees, and units go.
rows render as boxed key/value pairs. value is optional. Key is left-aligned, value right-aligned.
- A row that would spill past the bottom is silently dropped. Size the node to its rows (see Geometry).
edge -- a line between two parts, optionally carrying packets.
{ "type": "edge", "from": "client", "to": "store", "text": "GET <sha>", "packets": 4 }
from/to are part ids. Drawn centre-to-centre and trimmed to the box borders, so stacked and side-by-side both look right.
- A dangling
from/to renders nothing at all -- it looks like a broken renderer, not a broken document. Check your ids.
packets is how many squares travel the line while it is flowing (default 3). Set 0 for an edge that means a relationship rather than traffic. One trip takes 1.6s.
text draws a caption at the midpoint on an opaque background plate roughly max(40, len × 7.6 + 16) px wide. It will punch a hole through anything behind it. Only put text on an edge whose gap is wider than the plate.
label -- free-standing text.
{ "type": "label", "x": 56, "y": 48, "text": "COMMIT DAG", "align": "start", "caps": true }
align: start | middle | end (the anchor, at x).
caps: true gives the faint, tracked-out micro-caption style used for section headings.
- There is no font-size control. Every label is 12px. Do not try to build a large title; build hierarchy with
caps, tone, and position instead.
shape -- a plain rect or circle, with optional centered text.
{ "type": "shape", "shape": "rect", "x": 89, "y": 438, "w": 40, "h": 18, "tone": "accent" }
Shapes are how you show quantity, because text never changes (see Hard constraints). A grid of small shapes that go hidden one group at a time is a queue draining, a battery discharging, a work list being claimed.
html -- freeform markup, for the things the primitives above cannot express: real typography, a type scale, flow layout, a UI that has to look like a real product rather than like a diagram of it.
The markup comes from one of two places:
{ "type": "html", "x": 55, "y": 108, "w": 1090, "h": 534,
"htmlFile": "./partials/app-window.html",
"vars": { "title": "acme-api", "branch": "main" } }
htmlFile is a path to a .html file next to the document. Relative only; .. is allowed, absolute is refused.
html is markup inline. Right for a few lines, wrong for a widget.
vars fills {{name}} placeholders in whichever source won. Values are HTML-escaped, so a var is text and cannot change the structure of the partial it lands in. An unfilled placeholder resolves to empty, never to its own name. That is the entire template language -- no conditionals, no loops, no expressions.
Three things about html parts that will otherwise cost you a build:
--anim-tone inherits into the markup. Give a border, a badge, a button color: var(--anim-tone) plus a transition, and a step's tone change animates the whole widget. This is how you avoid a second copy of the markup for a second state.
- Script, event handlers,
<style> and non-https/data:image urls are stripped at the render boundary. Inline style= attributes are fine.
- An opaque
html part hides every edge underneath it. Edges are drawn before all non-edge parts, so a background on a container is enough to make a nested diagram's lines vanish with no error. Keep containers transparent and set stage.background to supply the surface colour instead.
stage.theme
stage may carry a stamped palette that every renderer reads:
"stage": { "width": 1200, "height": 675, "fps": 25, "theme": {
"bg": "#1c1c1c", "border": "#383838", "accent": "#3b82f6",
"--nim-panel": "#2f2f2f"
} }
Keys naming a stage token (bg, surface, surfaceRaised, border, borderStrong, text, textMuted, textFaint, accent, success, warning, error, purple) override that token. Keys of the form --some-name are emitted as extra custom properties, which is how a project carries its own vocabulary into the stage. Anything else is dropped.
Values, not a theme name: no renderer then needs a theme registry, and the extension stays neutral about whose product this is. The cost is that editing a theme in the app does not reach existing documents until they are restamped.
Write markup against token names, not literal hex. var(--anim-border) rather than #383838 means switching palettes is a one-field edit with nothing to redraw. Reserve literal colours for things that genuinely are fixed -- macOS traffic lights.
Note that stage.background, if set, still wins over theme.bg.
sub-parts
Markup inside one html part can declare regions a step addresses individually:
<div class="anim-subpart" data-part="chrome/session-a">…</div>
A step then writes "set": { "chrome/session-a": { "tone": "success" } }, and the stage drives it with the same querySelector + setAttribute it uses for a top-level part. Declare each one in the part's subParts map so the baseline resolver knows it exists:
"subParts": { "session-a": { "tone": "accent" }, "session-b": {} }
Two things to know:
- The class is
anim-subpart, never a nested anim-part. .anim-part sets --anim-tone to neutral unconditionally, so a nested one resets to grey instead of inheriting its container's tone.
- For a sub-part,
neutral means "inherit", not "grey". That is what lets a whole window go accent without every region inside it snapping back.
Writing data-part by hand is fine for two or three regions. Past that, the markup is worth compiling from a component -- see the compiler below.
The spinner utility (anim-spin)
The stage ships one rotating primitive for html parts. Give any element class="anim-spin" and it becomes a lit ring turning at ~0.8s -- a running or loading indicator that reads as live rather than a static glyph. It is the rotational counterpart to the edge packet, and the only self-driven rotation the format has.
<span class="anim-spin" style="width:8px;height:8px"></span>
- Colour is
currentColor, so whatever wraps it sets the hue -- drop it inside a blue "running" pill and the ring is blue, no extra styling.
- It spins on its own, across every step. You do not drive it from
set; it is a CSS animation, not a state. To make it stop, hide the part or sub-part it lives in on the step the work finishes (put it in its own anim-subpart if only the spinner should disappear).
- It freezes but stays visible while the playhead is scrubbed and under
prefers-reduced-motion, and both recorders capture it exactly as they capture packets -- so it survives export_html and export_gif.
Reach for it wherever the real UI shows a spinner: an agent session mid-run, a build in progress, a request in flight. It is the honest way to show "this is working right now" without faking motion the format cannot do.
Building a kit for your project
Nothing product-specific ships with this extension, and that is deliberate: the markup worth reusing is always your product, so anything bundled here could only ever be somebody else's app.
Instead, build a small kit once and reuse it across every animation you make:
docs/animations/
partials/
app-window.html <- your chrome: title bar, sidebar, main pane
list-row.html <- one row, used eight times with different vars
toolbar.html
onboarding.anim.json
sync-explainer.anim.json
Three rules make a kit that lasts:
- Go look at the real thing first. Chrome drawn from memory is wrong in structure, not just styling, and structural wrongness is invisible to you and instantly obvious to anyone who uses the product. Screenshot the app and draw against it.
- Simplify hard, then make one thing big. Keep only what makes it recognisable at a glance; cut every pane that is not part of the story. Then pick one hero and draw it at a size you can actually read.
- Decide loop-vs-part per piece. Markup that stays put lives inside a bigger partial. Anything a step changes is either its own top-level part positioned over the container, or -- if the container is a compiled component -- a sub-part the component declares. A
.html partial has no way to declare one, so with partials a list's eight resting rows are one file and the row that turns green is its own part with its own coordinates.
A partial has no defaults -- a var you leave out renders as empty, not as an error -- so document the vars each one expects at the top of the file in a comment.
Compiling components (optional, needs Node)
vars is deliberately not a template language: no conditionals, no loops, no types, no defaults. When that starts to hurt -- a list whose length varies, a phase colour repeated across fifty documents, a region you want a step to address -- author the markup as a .tsx component instead and compile it into the part:
{ "type": "html", "x": 55, "y": 108, "w": 1090, "h": 534,
"component": "./components/DesktopWindow.tsx",
"props": { "sessions": [ { "id": "sync", "title": "Sync explainer", "phase": "implementing" } ] } }
node packages/extensions/animation/tools/anim-compile.mjs docs/onboarding.anim.json [--theme dark] [--check]
The compiler renders the component with those props and writes three fields back: html (the markup, sanitizer-clean), subParts (the regions the markup actually declared) and build (hashes of the props and of the component sources). Nothing renders the component at play time. Every consumer keeps drawing the same static string, which is the only reason they agree frame for frame -- and one of them, the offscreen screenshot host, has no filesystem to resolve a component from at all.
What you get that vars cannot give you: real conditionals, typed props (a mistyped phase name is an error before anything renders), shared defaults, children for static panes, and AnimPart -- a wrapper that declares a step-addressable region from the data it was handed, so twelve kanban cards need no coordinates and a thirteenth needs no edit to the other twelve.
It lints as it goes and refuses to write on a failure: markup that the sanitizer would alter, a step addressing a part or sub-part that does not exist, components that do not typecheck. It warns about an opaque container root and about a part sized away from the component's declared size.
Constraints worth knowing before you reach for it:
- Sub-part ids come from your data, so renaming
sessions[0].id rewires the steps addressing it. The step-target lint turns that into an error rather than a region that silently stops animating.
- Layering inside a component is DOM order, while layering between top-level parts is alphabetical id order. Two models.
subParts is generated. The compiler rewrites it from the markup on every run, so edit the component, not the map. A label you add by hand survives as long as the region does.
- v1 runs from a checkout of the Nimbalyst repo, because it bundles this extension's own document reader and writer from source so its output and the editor's save cannot drift.
htmlFile and inline html need none of this and are unchanged.
tones
neutral accent data success warning error muted
They map to theme tokens, so they follow the user's theme: accent is blue, data purple, success green, warning amber, error red, neutral/muted a faint grey. Assign them semantically and keep the meaning fixed for the whole animation -- if amber means "under review" in step 4 it cannot mean "slow" in step 7.
states
The state vocabulary is defined by the stylesheet, not the schema. Any other string parses fine and renders as idle, so a typo fails silently.
| Type | States |
|---|
node | idle (default), active (tinted fill, tone border, status dot, first row highlighted), waiting (dashed amber border), offline (dashed red border, dimmed title), hidden |
edge | idle (default, dashed grey), flowing (line fills in, packets travel from -> to), returning (same, packets travel backwards), active (same as flowing), hidden |
label | idle (default), active (takes its tone colour), hidden |
shape | idle (default, 14% tone fill), active (65% tone fill, reads as solid), hidden |
returning is the most useful state in the set and the most under-used: a reply, a rejection, a rollback travelling back down the same wire. Reversing packets beats drawing a second edge -- two overlapping edges between one pair of nodes look like a rendering fault.
steps
{ "id": "response", "duration": 1000,
"caption": "The objects come back down the same wire.",
"set": { "fetch": { "state": "returning", "tone": "success" } } }
id is a readable slug, unique. It shows in the step strip.
duration is how long this step holds before the next begins, in ms (1..600000; the editor's drag-to-retime floor is 40ms).
caption is one sentence of narration. Read end to end, the captions should form a coherent paragraph -- they are also the animation's accessibility description.
set maps part id -> { state?, tone? }. Omit either and it inherits.
Playback loops by default, and the wrap is a hard cut: the stage jumps from your last step straight to the baseline-plus-first-step. Design that cut deliberately -- it should read as a reset, not as a glitch.
Geometry
Layout is hand-placed, so these numbers matter.
Node internals. Header is 34px. The subtitle baseline sits at y+56. Rows start at y+72 (or y+56 with no subtitle), each row 26px tall with a 6px gap -- 32px of pitch. A row is dropped if it would come within 6px of the bottom.
Node height: h = 32 × rows + 80 with a subtitle, h = 32 × rows + 64 without.
Three rows plus a subtitle -> h = 176. Going much taller leaves a visible dead band under the last row.
Row text is 11px mono: key at x+28, value right-aligned at x + w − 28. At w = 228 you have room for roughly a 6-character key and a 12-character value.
Edges. Keep every edge between adjacent boxes. An edge routes as a straight centre-to-centre line trimmed to the borders, so a diagonal across a grid will run straight through the cards in between. If two boxes you want to connect are not neighbours, move them.
Leave the gap wide enough for what the edge carries: ~40px for a bare edge with packets, ~70px if it has text.
Layering. Edges draw first (behind everything), then all other parts in alphabetical id order. That is the only layering control there is. To draw a part on top of another, give it an id that sorts later: queue (the panel) then queueTask01..queueTask12 (the chiclets inside it).
Canonical form
The editor rewrites the file on save with a fixed key order. Hand-write it in canonical order or your first save will reformat the whole file and bury the real edit in the diff.
- Root:
version, stage, parts, steps
stage: width, height, fps, background
parts: sorted alphabetically by id. Within a part: type, label, tone, state, then
- node:
x, y, w, h, subtitle, rows
- edge:
from, to, text, packets
- label:
x, y, text, align, caps
- shape:
x, y, w, h, shape, text
steps: document order -- it is the animation. Within a step: id, duration, caption, set. set keys sorted alphabetically; each assignment state then tone.
- Two-space indent, one trailing newline.
Unknown keys are preserved and written after the known ones in sorted order, so a field this build does not model still round-trips.
Hard constraints
Design around these; they are not bugs to work around.
- No text changes. No part's
label, text, subtitle, or rows can differ between steps. A counter that ticks 36 -> 24 -> 12 is impossible. Show quantity with shapes going hidden, and write static captions that stay true for the whole run ("CLAIMED IN ORDER", not "16 REMAINING").
- No movement.
x/y are fixed. Parts appear, disappear, and change colour; they do not travel. The only continuous motion in the format is edge packets and the anim-spin spinner (see sub-parts). Nothing else rotates, slides, or eases.
- No font sizes. Labels are 12px, node titles 13px, rows and subtitles 11px.
- No z-index. Alphabetical ids, as above.
- No per-step easing or delay. One transition duration (320ms) for everything.
Making a good one
Structure
- 8 to 12 steps. Fewer feels like a slideshow, more and the viewer loses the thread.
- 600-1200ms per step. Under 400ms nobody reads the caption; over 1500ms it drags. Give the beat where something changes meaning the longest hold.
- One idea per step. If a caption needs "and", it is two steps.
- Total 8-15 seconds. It loops; it does not need to be a documentary.
Layout
- Put it on a grid. Align tops and bottoms across columns. Equal gutters. The format has no auto-layout, so sloppy coordinates read as a sloppy diagram.
- Give it a header (a title label and a caps subtitle) and a bottom rail of caps labels that light in sequence. That rail is cheap and does more for legibility than anything else: it tells the viewer where they are in a process they have not seen before.
- Group with proximity, not boxes. Two columns 70px apart with a caps heading over each beat any amount of nesting.
- Flow left to right, or top to bottom. Pick one.
Motion
- Turn things off. The single most common failure is an animation where every part is lit by the end, so the final frame is noise. Set edges back to
idle once their traffic is done.
- Land on a resolution. The last step should look settled -- one tone, everything quiet - not mid-flight.
- Animate the interesting part. The beat worth the viewer's attention is almost never the happy path. It is the retry, the cache miss, the review that sends the work back. Use
waiting and returning for it. An explainer that only shows success explains nothing.
- Do not light every part in step 1. Start quiet and let the scene fill in; that is most of the perceived quality.
Colour
- Two working tones plus grey carries most animations. Reach for a third only when it means a genuinely different thing.
- Let
neutral/muted do real work. Contrast comes from what is dim.
Workflow
- Sketch the steps first, in prose. Write the captions before you place a single coordinate. If the captions do not read as a paragraph, the animation will not read either.
- Place the scene on a grid. Nodes and their coordinates, then labels, then edges last -- edges are constrained by where the boxes ended up.
- Write the steps as deltas, in canonical order.
- Open it and look at it. Write to
<name>.anim.json, open it in Nimbalyst, and capture the stage with mcp__nimbalyst__capture_editor_screenshot. Scrub to a few different steps and capture each. Do not skip this -- coordinate arithmetic is exactly the kind of thing that is right in your head and wrong on screen.
- Iterate on what you see: dead space under a node's last row, an edge label punching through a card, two things lit that should not both be lit, a step you cannot read in time.
If the user gave you a style reference image, match its vocabulary -- caps micro-labels, card density, how much is dim at rest - rather than trying to reproduce it pixel for pixel. Say plainly which parts of it the format cannot express.
Exporting
animation.export_html writes a .anim.json out as a self-contained HTML file that plays and loops on its own, with no external references. Point it at a path; it does not need the file open in an editor.
animation.export_html { filePath: "docs/cache.anim.json" }
-> docs/cache.html
Pass outputPath to put it somewhere else. It refuses to export a document with parse errors, and returns any warnings alongside the result. Clicking the exported page pauses it.
animation.export_gif writes an animated GIF instead, for destinations that cannot run a web page: a GitHub issue, a README, a chat message, a slide.
animation.export_gif { filePath: "docs/cache.anim.json", fps: 12, maxWidth: 720 }
-> docs/cache.gif
Prefer export_html whenever the destination can display a web page. The GIF is recorded by playing the animation in an offscreen window and capturing it in real time, so it takes about as long as the animation runs, it is capped at 256 colours, and it is one to two orders of magnitude larger than the HTML. The HTML is instant, sharp at any size, and a few tens of kilobytes.
All four renderers -- the editor preview, export_html, both recorders -- use the palette in stage.theme, or a fixed dark fallback when the document names none. They agree by construction. This used to be untrue: the preview read the app's live theme while every export hardcoded the fallback, so the same document was two different colours depending on who asked, and the authoring advice worked around it by telling you to hardcode your palette. That advice is withdrawn.
Keep GIFs small: fps 8-12 is plenty for this kind of diagram, and maxWidth 720 is usually enough. Both file size and recording memory scale with the square of the width.
There is no video export. If someone wants MP4, say so rather than implying the GIF is a substitute.
Showing it live inside Nimbalyst
To present a finished animation to someone inside Nimbalyst you do not export at all. A .anim.json has a registered custom editor, so a link to it alone on its own paragraph in a markdown document upgrades into the live, playing embed:
[Session kanban](/abs/path/board.anim.json "width=1040 height=585")
That is the right way to hand over an existing animation -- it is live and full-fidelity, where a screenshot is frozen and (for an animation) misleading. Two things decide whether it works:
- The link must be the only thing in its paragraph (no other text, no second link), or it stays a plain link.
- A prose link only upgrades in the document (Lexical) editor. A link pasted into AI transcript / chat prose does not upgrade -- put it in an actual
.md document and open it. Absolute paths work; the file may be anywhere on disk.
When you author the .anim.json yourself, you do not need the link at all. This editor sets supportsTranscriptEmbed in its manifest, so the moment your Write/Edit creates or changes a .anim.json, the transcript renders it inline in the tool card as a live, click-to-activate stage. That is the meta payoff -- the agent writes the scene and it plays right there in the session. (This is a per-editor opt-in; editors that don't set the flag show a plain file row.)
capture_editor_screenshot is for your iteration in the workflow above (checking coordinates), not for presenting the finished piece -- present that with the inline embed.
Why frames cannot be stamped
Worth knowing, because it rules out the obvious shortcut: interpolation is CSS transitions and the packets are CSS animations, so writing data-state into a series of snapshots and stitching them gives a stepped slideshow with motionless packets. Any frame-based output has to drive a real browser through real playback. Do not build a frame stitcher.
Common mistakes
| Symptom | Cause |
|---|
| An edge is invisible | from/to names a part that does not exist. Dangling edges render as nothing. |
| A row is missing from a node | h is too small. h = 32 × rows + 80 with a subtitle. |
| A state does nothing | Typo. Unknown state strings parse fine and render as idle. |
| An edge label sits on top of a card | The gap is narrower than the label plate. Widen the gap or drop the text. |
| A part is hidden behind another | Alphabetical draw order. Rename it to sort later. |
| Something stays lit forever | Cumulative states. You never set it back to idle. |
| The whole file reformats on first save | It was not written in canonical order. |
| A line crosses a card | The two boxes are not adjacent. Move them, do not fight the router. |
Worked example
A complete, canonical, three-part file. Copy it and grow it.
{
"version": 1,
"stage": {
"width": 1080,
"height": 420,
"fps": 25
},
"parts": {
"caption": {
"type": "label",
"x": 80,
"y": 48,
"text": "READ THROUGH CACHE",
"caps": true
},
"cache": {
"type": "node",
"label": "Cache",
Note what it does: starts quiet, holds longest on the beat that costs 18ms, uses waiting for the stall and returning for both replies, sets fetch and load back to idle once they are done, and ends on one tone with the origin dimmed back out.
If you are working in the Nimbalyst repo itself, packages/extensions/animation/samples/ has two longer references: git-storage.anim.json (a request crossing a network) and meta-agent-sessions.anim.json (a grid of cards, a draining queue, and a review that sends work back).