Word entrances
The hero headline staggers a random effect per character; this is the same trick at word scale — but nothing is random. Each word is handed a different entrance by index: drop, spin, pop, blur, flip, slide, zoom, rise. The stagger callback receives (el, i), so it can deal effects like cards.
Every single word makes its own grand entrance
view the scene()
const wordEffects = [
{ from: { opacity: 0, transform: 'translateY(-1.4em)' }, to: { … }, easing: 'bounce-out' }, // drop
{ from: { opacity: 0, transform: 'rotate(-180deg) scale(0.3)' }, … }, // spin
{ from: { opacity: 0, transform: 'scale(0.1)' }, easing: 'elastic-out', … }, // pop
{ from: { opacity: 0, filter: 'blur(14px)' }, … }, // blur
// …flip, slide, zoom, rise
];
scene()
.stagger(
words,
(el, i) => wordEffects[i % wordEffects.length], // dealt by index, not pick()
{ interval: 150 }
)
.play();
Drifting decorations
Each leaf runs its own alternate loop with a rand() duration, so they sway at unrelated frequencies — the crowd never marches in step. Calm, organic, zero timeline bookkeeping.
view the scene()
leaves.forEach((leaf) => {
scene({ loop: true, alternate: true, defaults: { easing: 'ease-in-out' } })
.fromTo(
leaf,
{ transform: 'translate(-9px, -6px) rotate(-6deg)' },
{ transform: 'translate(9px, 6px) rotate(6deg)' },
{ duration: rand(3200, 6000) } // own frequency
)
.play();
});
Snow field
Thirty flakes, each an infinite loop. Because rand() values are lazy — re-rolled at the start of every iteration — each flake respawns in a new column at a new speed on every fall. Nothing is baked in at build time.
view the scene()
scene({ loop: true, defaults: { easing: 'linear' } })
// lazy left → a new column every loop; transform reset parks it at the top
.set(flake, { left: rand(0, 100, '%'), opacity: 0, transform: 'translateY(-20px) translateX(0px)' })
// sparse keyframes fill like CSS: opacity keys don't disturb the transform track.
// The fall ends AT the faded-out point, then the loop repositions.
.frames(flake, {
0: { opacity: 0, transform: 'translateY(-20px) translateX(0px)' },
10: { opacity: 0.9 },
30: { transform: 'translateY(150px) translateX(16px)' },
55: { transform: 'translateY(280px) translateX(-14px)' },
80: { opacity: 0.9, transform: 'translateY(385px) translateX(12px)' },
100: { opacity: 0, transform: 'translateY(450px) translateX(2px)' }, // fades out inside the stage
}, { duration: rand(4000, 9000) }) // new speed every loop
.play();
Easing gallery
The same slide, six timing curves, launched together so you can watch them race. Overshoot presets (back-out, elastic-out) intentionally cross past their target and settle back.
view the scene()
const curves = ['linear', 'ease-in-out', 'back-out',
'elastic-out', 'bounce-out', 'cubic-bezier(0.34,1.56,0.64,1)'];
scene().parallel((b) => {
boxes.forEach((box, i) =>
b.fromTo(box,
{ transform: 'translateX(0px)' },
{ transform: () => `translateX(${trackWidth(box)}px)` },
{ duration: 1500, easing: curves[i] }));
}).play();
Physics vs. easing
Left: a fixed 800 ms back-out curve — same every time. Right: a physics spring with no duration, settling when it settles. Drive the spring's attraction and friction and feel the difference.
view the scene()
scene().parallel((b) => {
b.fromTo(easeBox, from, to, { duration: 800, easing: 'back-out' });
b.fromTo(physBox, from, to, { physics: { attraction: a, friction: f } });
}).play();
Scene chaining
A five-beat story from one scene: the orb rises, four satellites spin in on cascading delays, ride a full elliptical orbit whose keyframes are generated by a plain JS loop, then dive into the core with back-in anticipation. The finale reverses the opening — every loop ends exactly where it began, so the restart is seamless. Dial timeScale down to inspect the choreography in slow motion.
view the scene()
// a full 360° ellipse as generated keyframes — satellites spin once en route
const orbit = (deg) => {
const f = {};
for (let i = 0; i <= 8; i++) {
const p = point(deg + i * 45); // x/y on the rx×ry ellipse
f[Math.round(i * 12.5)] = { transform: `translate(${p.x}px, ${p.y}px) rotate(${i * 45}deg) scale(1)` };
}
return f;
};
scene({ loop: true, loopDelay: 600 })
.set(orb, hidden) // satellites .set() to their fly-in poses too
.call(() => status('1 / 5 · rise'))
.to(orb, risen, { duration: 650, easing: 'back-out' })
.call(() => status('2 / 5 · assemble'))
.parallel((p) => sats.forEach(({ el, deg }, i) =>
p.to(el, { opacity: 1, transform: formation(deg) },
{ duration: 700, easing: 'back-out', delay: i * 90 })))
.call(() => status('3 / 5 · orbit'))
.parallel((p) => sats.forEach(({ el, deg }) =>
p.frames(el, orbit(deg), { duration: 1700, easing: 'ease-in-out' })))
.call(() => status('4 / 5 · absorb'))
.parallel((p) => {
sats.forEach(({ el }, i) =>
p.to(el, { opacity: 0, transform: 'translate(0px, 0px) rotate(540deg) scale(0.2)' },
{ duration: 520, easing: 'back-in', delay: i * 70 }));
p.to(orb, flash, { duration: 600, easing: 'elastic-out', delay: 420 });
})
.call(() => status('5 / 5 · descend'))
.to(orb, hidden, { duration: 700, easing: 'ease-in' }) // ends where it began — seamless loop
.play();
Spring chase
Every pointer press starts a fresh .to() spring. Click again mid-flight and last-write-wins interruption cancels the old tween, so the orb bends toward its new target without bookkeeping.
view the scene()
AnimationEngine.registerPhysics(PhysicsEngine);
// The chase's own spring — snappier than the compare demo's sliders
const CHASE_SPRING = { attraction: 0.08, friction: 0.14 };
scene()
.set(springOrb, {
transform: () => `translate(${(springStage.clientWidth - springOrb.offsetWidth) / 2}px, ${(springStage.clientHeight - springOrb.offsetHeight) / 2}px)`,
})
.play();
springStage.addEventListener('pointerdown', (event) => {
const rect = springStage.getBoundingClientRect();
const x = Math.max(0, Math.min(rect.width - springOrb.offsetWidth,
event.clientX - rect.left - springOrb.offsetWidth / 2));
const y = Math.max(0, Math.min(rect.height - springOrb.offsetHeight,
event.clientY - rect.top - springOrb.offsetHeight / 2));
scene()
.to(springOrb, { transform: `translate(${x}px, ${y}px)` }, {
physics: CHASE_SPRING,
})
.play();
const ring = document.createElement('div');
ring.className = 'spring-ring';
ring.style.left = event.clientX - rect.left + 'px';
ring.style.top = event.clientY - rect.top + 'px';
springStage.appendChild(ring);
scene().frames(ring, {
0: { opacity: 0.8, transform: 'translate(-50%, -50%) scale(0.3)' },
100: { opacity: 0, transform: 'translate(-50%, -50%) scale(3.6)' },
}, { duration: 520, easing: 'ease-out' })
.play()
.then(() => ring.remove());
});
springCenterBtn.addEventListener('click', () => {
scene().to(springOrb, {
transform: () => `translate(${(springStage.clientWidth - springOrb.offsetWidth) / 2}px, ${(springStage.clientHeight - springOrb.offsetHeight) / 2}px)`,
}, { physics: CHASE_SPRING }).play();
});
Ripple grid
Built-in from: modes rank dots by their one-dimensional index. Click a dot and a config function adds per-dot delay instead, turning the same .stagger() into a true radial wave.
view the scene()
const gridPulse = {
0: { opacity: 0.35, transform: 'scale(1)', filter: 'drop-shadow(0 0 0px rgba(94,202,187,0))' },
40: { opacity: 1, transform: 'scale(1.9)', filter: 'drop-shadow(0 0 9px rgba(94,202,187,0.9))' },
100: { opacity: 0.35, transform: 'scale(1)', filter: 'drop-shadow(0 0 0px rgba(94,202,187,0))' },
};
const pulseConfig = { keyframes: gridPulse, duration: 450, easing: 'ease-in-out' };
let gridScene = null;
function playGridFrom(from) {
if (gridScene) gridScene.stop();
gridScene = scene().stagger(gridDots, pulseConfig, {
interval: 18, from: from,
});
gridScene.play();
}
function playRadialGrid(origin) {
if (gridScene) gridScene.stop();
const originRow = Math.floor(origin / GRID_COLUMNS);
const originCol = origin % GRID_COLUMNS;
gridScene = scene().stagger(gridDots, (_, i) => {
const row = Math.floor(i / GRID_COLUMNS);
const col = i % GRID_COLUMNS;
const distance = Math.hypot(row - originRow, col - originCol);
return { ...pulseConfig, delay: distance * 26 };
}, { interval: 0 });
gridScene.play();
}
Confetti cannon
Seventy disposable scenes launch on every press. rand() supplies each particle’s size, angle, arc, drift, spin and duration; one unevenly spaced .frames() track fakes gravity, then removes its own element.
view the scene()
const confettiColors = ['#5ecabb', '#d9a441', '#e08a6f', '#86b5e8', '#e6eae6'];
function fireConfetti() {
for (let i = 0; i < 70; i++) {
const particle = document.createElement('div');
particle.className = 'confetti-particle';
particle.style.width = rand(5, 11, 'px')();
particle.style.height = rand(4, 9, 'px')();
particle.style.borderRadius = pick(['50%', '2px'])();
particle.style.background = pick(confettiColors)();
confettiStage.appendChild(particle);
const angle = rand(-150, -30)();
const radians = angle * Math.PI / 180;
const direction = Math.cos(radians) < 0 ? -1 : 1;
const drift = direction * (Math.abs(Math.cos(radians)) * 120 + rand(35, 110)());
const height = rand(180, 270)();
const spin = rand(-960, 960)();
const fall = confettiStage.clientHeight + 40;
scene().frames(particle, {
0: { opacity: 1, transform: 'translate(0px, 0px) rotate(0deg)' },
22: { transform: `translate(${drift * 0.28}px, ${-height * 0.78}px) rotate(${spin * 0.18}deg)` },
38: { transform: `translate(${drift * 0.48}px, ${-height}px) rotate(${spin * 0.36}deg)` },
54: { transform: `translate(${drift * 0.66}px, ${-height * 0.72}px) rotate(${spin * 0.56}deg)` },
100: { opacity: 0, transform: `translate(${drift}px, ${fall}px) rotate(${spin}deg)` },
}, { duration: rand(1200, 2200), easing: 'linear' })
.play()
.then(() => particle.remove());
}
}
Squash & stretch
One looping .parallel() keeps the ball and shadow in sync. Uneven keyframe spacing shapes the fall and rebound, while sparse shadow keys fill per property like CSS @keyframes.
view the scene()
const squashScene = scene({ loop: true }).parallel((p) => {
p.frames(squashBall, {
// apex sits only at the 0/100 seam — keyframe spacing traces a parabola,
// so the ball slows near the top but never freezes there
0: { transform: 'translateY(-170px) scaleX(1) scaleY(1)' },
14: { transform: 'translateY(-152px) scaleX(0.98) scaleY(1.03)' },
26: { transform: 'translateY(-105px) scaleX(0.94) scaleY(1.1)' },
36: { transform: 'translateY(-45px) scaleX(0.9) scaleY(1.15)' },
42: { transform: 'translateY(0px) scaleX(1.38) scaleY(0.62)' },
47: { transform: 'translateY(0px) scaleX(1.3) scaleY(0.68)' },
52: { transform: 'translateY(-18px) scaleX(0.92) scaleY(1.14)' },
62: { transform: 'translateY(-75px) scaleX(0.94) scaleY(1.1)' },
74: { transform: 'translateY(-125px) scaleX(0.97) scaleY(1.05)' },
88: { transform: 'translateY(-160px) scaleX(1) scaleY(1)' },
100: { transform: 'translateY(-170px) scaleX(1) scaleY(1)' },
}, { duration: 1400, easing: 'linear' });
p.frames(squashShadow, {
0: { opacity: 0.18, transform: 'scale(0.45)' },
26: { transform: 'scale(0.6)' },
36: { opacity: 0.4, transform: 'scale(0.8)' },
42: { opacity: 0.68, transform: 'scale(1.12)' },
47: { opacity: 0.6, transform: 'scale(1.05)' },
52: { opacity: 0.45, transform: 'scale(0.85)' },
74: { transform: 'scale(0.6)' },
100: { opacity: 0.18, transform: 'scale(0.45)' },
}, { duration: 1400, easing: 'linear' });
});
registerLoop(squashScene);
squashScene.play();
Spell sigil draw
SVG geometry works like any other target. Measured path lengths seed strokeDashoffset, then a .stagger() draws the seal before its nodes, core and final glow resolve in sequence.
view the scene()
const draw = scene();
paths.forEach((path) => {
const length = path.getTotalLength();
draw.set(path, { strokeDasharray: length, strokeDashoffset: length });
});
draw
.stagger(paths, (path) => ({
to: { strokeDashoffset: 0, opacity: 1 },
duration: 700, easing: 'ease-in-out',
}), { interval: 110 })
.parallel((p) => {
p.stagger(nodes, nodeReveal, { interval: 70, from: 'center' });
p.fromTo(core, hiddenCore, visibleCore, { duration: 900, easing: 'elastic-out' });
})
.frames(aura, glowPulse, { duration: 800 })
.play();
Arcana card deal
The outer wrappers deal into a responsive fan while the inner cards own the rotateY() reveal. Splitting those transform tracks avoids clobbering and makes the whole sequence easy to gather in reverse.
view the scene()
// Outer wrappers position the cards; inner elements own the 3D flip.
scene()
.stagger(slots, (el, i) => ({
to: { transform: () => fanTransform(i) },
duration: 700, easing: 'back-out',
}), { interval: 85, from: 'center' })
.wait(140)
.stagger(cards, {
to: { transform: 'rotateY(180deg)' },
duration: 650, easing: 'back-out',
}, { interval: 100 })
.play();