@magic-spells/animation-engine

Animation
Engine

A declarative sequencing engine — chainable scenes with easing, physics, staggers, loops and lazy randomness. Every letter above arrived on its own stagger.

Install npm i @magic-spells/animation-engine
Size (gzip) 5.8 kB
01

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();
02

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();
});
03

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();
04

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();
05

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.

Easing800ms · back-out
Physicsspring
Attraction 0.020
Friction 0.20
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();
06

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.

idle
timeScale 1.0×
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();
07

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.

click around the stage · retarget mid-flight
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();
});
08

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();
}
09

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());
  }
}
10

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();
12

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();
13

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.

I
II
III
IV
V
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();