Domotion

generateAnimatedSvg()

Compose multiple captured frames into one animated SVG.

Stitches a list of captured frames into a single SVG with CSS keyframes between them. The output loops infinitely and runs without JavaScript — drop it into <img src="demo.svg">.

Signature

function generateAnimatedSvg(config: AnimationConfig): string

See AnimationConfig for the full input shape. The minimum viable call is:

const svg = generateAnimatedSvg({
  width: 800,
  height: 400,
  frames: [
    { svgContent: frame0Svg, duration: 2000 },
    { svgContent: frame1Svg, duration: 2000 },
    { svgContent: frame2Svg, duration: 2000 },
  ],
});

What it does

  1. Sums up frame durations and per-frame transition durations into a total animation length.
  2. If every transition is crossfade (or unset) and there are no overlays, takes the merge fast path — de-duplicates elements across frames and emits per-element visibility timelines (smaller, no flicker).
  3. Otherwise, emits each frame as an opacity-animated <g> with the right transition keyframes (push-left uses translateX; scroll stays visible, fades only at the end).
  4. Renders every overlay (typing reveals, tap ripples) as additional <g> + @keyframes blocks on top of the relevant frame.
  5. Wraps everything in a viewport-clipped SVG with a dark background fill.

Returns

An SVG document string starting with the XML declaration. Pipe it through optimizeSvg() for production.

Example: three-frame crossfade

import { chromium } from "@playwright/test";
import {
  captureElementTree, elementTreeToSvg, generateAnimatedSvg, optimizeSvg,
} from "domotion";
import { writeFileSync } from "node:fs";

const WIDTH = 600, HEIGHT = 300;

const stages = [
  { html: "<div style='padding:24px'>Step 1: install</div>" },
  { html: "<div style='padding:24px'>Step 2: configure</div>" },
  { html: "<div style='padding:24px'>Step 3: ship 🚀</div>" },
];

const browser = await chromium.launch();
const page    = await browser.newPage();
const frames  = [];

for (let i = 0; i < stages.length; i++) {
  await page.setContent(stages[i].html);
  const tree = await captureElementTree(page, "body", {
    x: 0, y: 0, width: WIDTH, height: HEIGHT,
  });
  frames.push({
    svgContent: elementTreeToSvg(tree, WIDTH, HEIGHT, `f${i}-`),
    duration: 1500,
    transition: { type: "crossfade", duration: 300 },
  });
}

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

Pitfalls

  • Forgetting unique idPrefixes per frame. Frames re-use clip / gradient IDs by default; without distinct prefixes you'll get the wrong shapes after frame 0.
  • Overlays disable the merge fast path. If you want the smallest possible output and no flicker, capture overlay-style content (typed text, ripples) into the captured frames themselves rather than as overlays. Overlays are best for content that needs to span a transition or that wasn't in the captured DOM at all.
  • Transitions outside crossfade can't merge. A single push-left transition forces every frame onto the slower per-frame-atomic path. Keep all-crossfade sequences when you can.

See also