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.
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));// 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 mostidlems, default 1000).The first tap counts
A tap on the slot before the scene is up loads it at once and reaches
mountasfirst = { x, y, at }: an intent, not the original pointer event, whose pointer is gone by then.Reduced motion
With
prefers-reduced-motion: reducethe 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"andaria-labelstay. The scene gets its own stage (contain: strict), hidden from assistive tech until the scene returnsaccessible: true. Every change goes through a ledger, sodestroy()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.