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
| Type | When 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
- CLI reference — every flag, every transition type, every action type.
generateAnimatedSvg()AnimationConfig- Animation model