Domotion

Build an animated demo

Capture several frames, stitch them together, ship one SVG.

This is the recipe to reach for when you want a marketing-style "watch this happen" loop. We'll build a three-frame animation showing a sequence of states, with a short crossfade between frames.

The CLI handles the most common shape: a list of HTML files (or URLs), each held for some time, with a transition between them. For anything more exotic, drop down to the JS API.

1. Author one HTML file per frame

Put each frame's HTML in a sibling directory. Style with whatever you'd use in the real product — Domotion captures the rendered Chromium frame, not the source.

demo/
├─ frames/
│  ├─ step-1.html
│  ├─ step-2.html
│  └─ step-3.html
└─ demo.json

Example step-1.html:

<!doctype html>
<html><body style="margin:0;font-family:-apple-system,sans-serif;background:#0d1117;color:#e6edf3;">
  <div style="padding:32px;display:flex;gap:20px;align-items:center;">
    <div style="
      width:64px;height:64px;border-radius:14px;background:#79b8ff;color:#0d1117;
      display:flex;align-items:center;justify-content:center;font-size:14px;font-weight:700;
    ">Step 1</div>
    <div style="font-size:24px;font-weight:600;">Install the package</div>
  </div>
</body></html>

2. Write the config

demo.json:

{
  "width":  600,
  "height": 240,
  "output": "steps.svg",
  "optimize": true,
  "frames": [
    {
      "input":      "./frames/step-1.html",
      "duration":   2000,
      "transition": { "type": "crossfade", "duration": 300 }
    },
    {
      "input":      "./frames/step-2.html",
      "duration":   2000,
      "transition": { "type": "crossfade", "duration": 300 }
    },
    {
      "input":      "./frames/step-3.html",
      "duration":   2200,
      "transition": { "type": "crossfade", "duration": 300 }
    }
  ]
}

3. Render

domotion animate demo.json

Result: steps.svg — one self-contained file, no JavaScript at runtime, looping forever.

Picking a transition

TypeWhen to use
crossfade Default. State change within the same screen — text updating, badge appearing, list item highlighting. Takes the merge fast path for smaller output and no flicker.
push-left Stepping through a clearly delimited sequence — onboarding, wizard steps, "before / during / after" stories.
scroll Long content that logically continues, where the previous frame should remain visible while the next slides up.

Tuning duration

  • Hold time (frame duration) should be long enough to read what's on the frame. For text-heavy frames, 1500–3000 ms. For frames that are the punchline of the previous transition, 2500 ms+.
  • Transition time should be short — 250–400 ms. Longer transitions feel sluggish; shorter ones feel jumpy.
  • Total loop typically lands at 6–15 s. Anything longer and viewers leave before seeing the full sequence.

Capturing a real interaction

Frames don't have to be static HTML — they can be a live URL with actions run before capture. This is how you record "user clicks the button, then this happens":

{
  "width": 800, "height": 400,
  "frames": [
    {
      "input":    "http://localhost:3000/cart",
      "duration": 1500,
      "transition": { "type": "crossfade", "duration": 300 }
    },
    {
      "input":    "http://localhost:3000/cart",
      "duration": 2000,
      "transition": { "type": "crossfade", "duration": 300 },
      "actions": [
        { "type": "click", "selector": ".checkout-btn" },
        { "type": "wait",  "ms": 600 }
      ]
    }
  ]
}

Available actions: click, fill, press, hover, scroll, wait. See CLI actions.

Adding overlays

For typing animations and tap ripples that aren't part of the captured HTML, add an overlays array to a frame — see Typing & tap overlays.

Overlays drop the animation off the merge fast path — the SVG will be larger and may show a one-frame flicker on transitions. Where the overlay content can be part of the captured HTML instead, prefer that.

When to drop down to the API

Use the JS API instead of the CLI when:

  • You need a custom interaction the JSON action vocabulary can't express (drag-and-drop, multi-step flows where each action depends on a captured value).
  • You're composing frames programmatically — e.g. one frame per row of a database query.
  • You're integrating capture into an existing test harness and want to share the browser context.

The API equivalent of the JSON config is a loop over captureElementTree + elementTreeToSvg calls passed to generateAnimatedSvg:

import { writeFileSync } from "node:fs";
import {
  captureElementTree, elementTreeToSvg,
  generateAnimatedSvg, optimizeSvg, launchChromium,
  type AnimationFrame,
} from "domotion";

const WIDTH = 600, HEIGHT = 240;
const stages = ["./frames/step-1.html", "./frames/step-2.html", "./frames/step-3.html"];

const browser = await launchChromium();
const context = await browser.newContext({ viewport: { width: WIDTH, height: HEIGHT } });
const page    = await context.newPage();

const frames: AnimationFrame[] = [];
for (let i = 0; i < stages.length; i++) {
  await page.goto(`file://${process.cwd()}/${stages[i]}`);
  await page.evaluate(() => document.fonts.ready);
  const tree = await captureElementTree(page, "body", { x: 0, y: 0, width: WIDTH, height: HEIGHT });
  frames.push({
    svgContent: elementTreeToSvg(tree, WIDTH, HEIGHT, `f${i}-`),
    duration: 2000,
    transition: { type: "crossfade", duration: 300 },
  });
}

const svg = optimizeSvg(generateAnimatedSvg({ width: WIDTH, height: HEIGHT, frames }));
writeFileSync("steps.svg", svg);
await browser.close();

Note the per-frame idPrefix (`f${i}-`) — without it, clip / gradient IDs from different frames would collide.

See also