Skill 18 · Hyperframes Animation
Subchapter 18.90
rules/particle-burst.mdMarkdown10 KBView on GitHub
Discrete flying particles as a one-shot event: a confetti pop that erupts upward and drifts back down on gravity, a dot burst radiating from behind a landing word, or a glyph dissolve where text breaks into particles that scatter and die. Particles are ephemeral garnish — born from a beat, fly, gone; they never become layout.
Boundaries: css-marker-patterns.md‘s burst mode is radiating drawn lines — a static accent, no flight. press-release-spring.md‘s release burst is one blurred radial layer faking an explosion — enough when a single glow pop will do. center-outward-expansion.md moves real layout elements to final resting slots; particles have no destination, only physics and a death.
The whole event is one driver tween and one formula:
Seeded setup — a fixed pool of PARTICLE_COUNT small divs is created once at composition setup (a deterministic loop — setup-time generation is fine; per-frame DOM creation is not). Each particle i derives everything from a pure hash:
// angle, speed, size, spin, color (palette[i % palette.length]) — all from prand(i * k)
const prand = (n) => {
const x = Math.sin(n * 127.1 + 311.7) * 43758.5453;
return x - Math.floor(x); // 0..1, pure function of n
};Ballistic formula — a proxy tween advances T: 0 → 1 over FLIGHT_DUR with ease: "none"; onUpdate positions every particle as a pure function of T:
x(T) = vx · T·FLIGHT_DUR
y(T) = vy · T·FLIGHT_DUR + ½ · G · (T·FLIGHT_DUR)²
rot(T) = spin · T·FLIGHT_DURGravity G supplies the rise-decelerate-fall arc for free. Because position is computed from T (never accumulated per frame), a seek to any moment renders the exact mid-flight state — this is what makes DOM particles seek-safe. The driver’s ease: "none" is load-bearing: the physics lives in the formula; an eased driver warps gravity and the arc stops reading as thrown objects.
Death — an opacity tail inside the same formula (fade over the last FADE_FRAC of flight), or the confetti signature: a separate instant-shrink tween scaling the pool to 0 in a blink at flight end. Either way the particles end invisible and stay invisible.
<!-- inside a standard scene clip (hyperframes-core) -->
<div class="burst-stage">
<div class="particle-field" id="particle-field"></div>
<div class="burst-hero" id="burst-hero">{heroWord}</div>
</div>/* .burst-stage: position: relative; display: grid; place-items: center.
.burst-hero: z-index: 2 — particles fly BEHIND the word. */
.particle-field {
position: absolute;
z-index: 1;
left: 50%;
top: 50%; /* the launch origin — offset to taste (e.g. the word's baseline) */
width: 0;
height: 0;
}
.particle {
position: absolute;
left: 0;
top: 0;
border-radius: 2px; /* confetti chip; 50% for dots */
opacity: 0; /* invisible until the event fires */
will-change: transform, opacity;
}// Setup: deterministic pool, generated ONCE.
const field = document.getElementById("particle-field");
const palette = ["{accentA}", "{accentB}", "{accentC}"]; // 3-5 brand tokens
const parts = [];
for (let i = 0; i < PARTICLE_COUNT; i++) {
const el = document.createElement("div");
el.className = "particle";
const size = SIZE_MIN + prand(i * 3 + 1) * (SIZE_MAX - SIZE_MIN);
el.style.width = `${size}px`;
el.style.height = `${size * 0.7}px`; // slightly oblong = confetti chip
el.style.background = palette[i % palette.length];
field.appendChild(el);
// Index-seeded launch parameters — the particle's whole life, fixed here.
const angle = -Math.PI / 2 + (prand(i * 5 + 2) * 2 - 1) * CONE; // upward cone
const speed = SPEED_MIN + prand(i * 7 + 3) * (SPEED_MAX - SPEED_MIN);
parts.push({
el,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed, // negative = up
spin: (prand(i * 11 + 4) * 2 - 1) * SPIN_MAX,
});
}
// Confetti pop — one driver, pure ballistic formula.
const drive = { T: 0 };
tl.fromTo(
drive,
{ T: 0 },
{
T: 1,
duration: FLIGHT_DUR,
ease: "none", // physics lives in the formula, not the ease
onUpdate: () => {
const t = drive.T * FLIGHT_DUR; // seconds of flight — pure function of T
const fade = Math.min(1, (1 - drive.T) / FADE_FRAC); // opacity tail
parts.forEach((p) => {
const x = p.vx * t;
const y = p.vy * t + 0.5 * G * t * t; // rise, stall, drift down
p.el.style.transform = `translate(${x}px, ${y}px) rotate(${p.spin * t}deg)`;
p.el.style.opacity = String(drive.T === 0 ? 0 : fade); // T===0 guard covers seeks before the event
});
},
},
BURST_AT,
);FADE_FRAC near 0, plus tl.to(".particle", { scale: 0, duration: SHRINK_DUR, ease: "power2.in" }, BURST_AT + FLIGHT_DUR - SHRINK_DUR) with SHRINK_DUR 0.15–0.25s. Keep the whole event tiny relative to the subject — a garnish measured in a few dozen pixels, not a screen-filling cannon.angle = prand(i) * Math.PI * 2, G near 0, short flight (0.4–0.7s), round dots (border-radius: 50%), pool z-indexed behind the word. Fire at the word’s settle frame.ox = (prand(i*13) - 0.5) * BLOCK_W, same for oy, added inside the transform), gentle outward drift with low G; text fades out over the first ~30% of flight while particles fade in from its silhouette. Color every particle {textColor} so the swarm reads as the text’s own material. (True per-pixel dissolves are Canvas-2D territory — techniques.md; this DOM version sells it up to ~40 particles.)i % 10 < 3). Same formula, two windows.| token | range | notes |
| ——————— | ——————————————-- | ——————————————————————————- | — | ———————-- |
| PARTICLE_COUNT | 10–18 pop/dots; 24–40 dissolve | cap ~40 — per-frame style writes; past that, seek perf and register degrade |
| G | 900–1600 px/s² confetti; 0–200 dots/dissolve | natural fall vs drift |
| SPEED_MIN / SPEED_MAX | 250–700 px/s | per-particle via prand, never uniform |
| CONE | 0.35–0.8 rad (~20–45°) | wider = splash, narrower = fountain |
| FLIGHT_DUR | 0.7–1.4s | arc should peak ~35–45% of flight: check | vy | / G ≈ 0.4 × FLIGHT_DUR |
| SIZE_MIN / SIZE_MAX | 5–14px chips; 4–8px dots | on a 1080p frame |
| SPIN_MAX | 180–720 deg/s confetti; 0 dots | tumble |
| FADE_FRAC | 0.2–0.35 | near 0 when using instant-shrink |
| BURST_AT | on a cause | the word’s settle, a click, a lockup completing — an uncaused burst is noise |
"none" — x(T), y(T), rot(T) computed from the driver value every frame, never accumulated (+=) per tick (accumulation breaks the moment the renderer seeks); gravity is the ease — an eased driver bends the parabola.opacity: 0; the event only writes transform / opacity. PARTICLE_COUNT ≤ ~40 — per-frame style writes scale linearly; keep the event cheap.opacity: 0 — the drive.T === 0 guard covers seeks to before the event; the tail/shrink covers after. A chip frozen mid-air at driver end is a bug every subsequent frame.spring-pop-entrance (confetti fires on the hero’s settle frame) · kinetic-beat-slam (one beat earns the confetti payoff) · press-release-spring (single-layer glow alternative, or compose both) · css-marker-patterns (drawn-line burst when the accent should feel hand-annotated) · scale-swap-transition (glyph dissolve covers the exit).