DOM ENGINEV0.21.3FIZLET

02 GUIDE · A PAGE HERO

A hero that costs
nothing while it's still.

An Astro page engine renders each animation slot on a page as a sized, described placeholder. @fzlt/core/slot fills it with a scene that loads only near the viewport, never under reduced motion, and settles to zero frames once nothing moves. The heroes on this site's home page are built this way.

1. The slot

The page already has the figure, from the engine's [slot animation] block: <figure class="ae-slot" data-slot-target="home#hero" role="img" aria-label="…">. Its size, its placeholder and its text alternative are the page's. The scene only adds motion.

PAGE SCRIPT · page.ts
import { slot } from "@fzlt/core/slot";

// Loads only near the viewport, once the main thread is idle; never under reduced motion.
slot("home#hero", () => import("./hero-scene.js").then((m) => m.mount));
THE SCENE · hero-scene.ts
// hero-scene.ts: build inside the stage; return destroy.
import { createEngine } from "@fzlt/core";
import { effects } from "@fzlt/core/fx";
import type { SlotTap } from "@fzlt/core/slot";

export function mount(stage: HTMLElement, figure: HTMLElement, first: SlotTap | null) {
  // settle: no frames once nothing moves; fps: an ambient hero needs no more than 30.
  const engine = createEngine({ root: stage, pause: { reducedMotion: true }, settle: 0.5, fps: 30 });
  const canvas = engine.ledger.add(document.createElement("canvas"));
  canvas.width = stage.clientWidth;
  canvas.height = stage.clientHeight;
  stage.append(canvas);
  const ctx = canvas.getContext("2d")!, fx = effects();
  engine.add({
    step: (e, dt) => { fx.step(dt); if (fx.live) e.busy(); },
    render: () => { ctx.clearRect(0, 0, canvas.width, canvas.height); fx.draw(ctx); },
  });
  stage.addEventListener("pointerdown", (ev) => { fx.blast(ev.offsetX, ev.offsetY); engine.wake(); }, { signal: engine.signal });
  if (first) fx.blast(first.x, first.y); // the tap that loaded the scene
  engine.start();
  return { destroy: () => engine.destroy() };
}

2. What the slot does for you

  • Lazy

    Nothing loads until the slot comes within margin (default "200px") of the viewport, and then not before the main thread is idle (requestIdleCallback, at most idle ms, default 1000).

  • The first tap counts

    A tap on the slot before the scene is up loads it at once and reaches mount as first = { x, y, at }: an intent, not the original pointer event, whose pointer is gone by then.

  • Reduced motion

    With prefers-reduced-motion: reduce the scene never loads and the placeholder stays. If the setting turns on mid-scene, the scene goes and the placeholder comes back.

  • Accessible and contained

    role="img" and aria-label stay. The scene gets its own stage (contain: strict), hidden from assistive tech until the scene returns accessible: true. Every change goes through a ledger, so destroy() puts the slot back exactly.

3. Stay in budget

A hero's live chunk has a budget of 10 KB gzip. The page build (kernel, @fzlt/core/slot, @fzlt/core/input, @fzlt/core/fx) is 7,094 B, so the scene's own code gets about 3 KB. Sound, debug, the pad, the HUD, match and the Board UI never get pulled into it (a test checks).

settle: 0.5 and fps: 30 are the two lines that make it cheap: half a second after the last busy() the engine asks for no frames at all, and while it plays it asks for 30 a second, not one per vsync.