@magic-spells/timeline-engine

Timeline
Engine

Animations on one timeline. Drive it with the clock, a slider or the scroll bar.

Springs can't be scrubbed, so they play in real time when triggered.

Scroll to scrub
Install npm i @magic-spells/timeline-engine
Size (gzip)
5.8 kB timeline-engine alone (esm, deps external) 14.6 kB with every dependency (umd, deps inlined)
Uses

Scrub

Jump to any point in time with seek(), progress() or scrollDriver.

Trigger

Play springs in real time with tl.call() or viewTrigger().

Clips on one timeline

Seven clips with their own start times. Scroll to move the playhead, or drag the bars.

seven clips, one timeline

clips · playhead 0 ms
driver progress 0.000
view the timeline()
// Each orbit is a centred ring with its planet pinned to the top edge, so the
// clip animates one rotate() and the planet is carried around. The middle key
// is sparse — only opacity — so the rotation interpolates straight through it.
const orbitFrames = (from, to) => ({
  0:   { opacity: '0', transform: `rotate(${from}deg)` },
  22:  { opacity: '1' },
  100: { opacity: '1', transform: `rotate(${to}deg)` },
});

// One list drives both the timeline and the ruler drawn under the stage.
const heroClips = [
  // Sized so the LAST star lands on the timeline's own end: the 34th element
  // starts at 33 * 26 = 858, so 858 + 1340 ≈ 2200. Three twinkle cycles per star,
  // staggered across 34 of them, means the field never settles — and because it's
  // all keyframed, it scrubs backwards just as happily as forwards.
  { name: 'starfield', target: '.star-dot', at: 0, duration: 1340, easing: 'linear', stagger: 26,
    keyframes: { 0:   { opacity: '0',    transform: 'scale(0.3)' },
                 18:  { opacity: '1',    transform: 'scale(1)' },
                 34:  { opacity: '0.3',  transform: 'scale(0.72)' },
                 50:  { opacity: '0.95', transform: 'scale(1)' },
                 66:  { opacity: '0.35', transform: 'scale(0.78)' },
                 82:  { opacity: '1',    transform: 'scale(1)' },
                 100: { opacity: '0.7',  transform: 'scale(0.92)' } } },

  { name: 'sun', target: '#hero-sun', at: 200, duration: 900, easing: 'back-out',
    keyframes: { 0: { opacity: '0', transform: 'scale(0)' },
                 100: { opacity: '1', transform: 'scale(1)' } } },

  { name: 'orbit i',   target: '#orbit-1', at: 400, duration: 1500, easing: 'ease-out', keyframes: orbitFrames(-40, 185) },
  { name: 'orbit ii',  target: '#orbit-2', at: 550, duration: 1500, easing: 'ease-out', keyframes: orbitFrames(30, -145) },
  { name: 'orbit iii', target: '#orbit-3', at: 700, duration: 1500, easing: 'ease-out', keyframes: orbitFrames(-15, 130) },

  { name: 'comet', target: '#hero-comet', at: 1000, duration: 900, easing: 'ease-in-out',
    keyframes: { 0:   { opacity: '0', transform: 'translate(-320px, -150px) rotate(25deg) scale(0.6)' },
                 18:  { opacity: '1' },
                 72:  { opacity: '1' },
                 100: { opacity: '0', transform: 'translate(320px, 150px) rotate(25deg) scale(1)' } } },

  { name: 'headline', target: '#hero-headline', at: 1400, duration: 700, easing: 'back-out',
    keyframes: { 0: { opacity: '0', transform: 'translateY(26px)' },
                 100: { opacity: '1', transform: 'translateY(0px)' } } },
];

const heroTl = timeline();
// Each config doubles as the opts object — name/target/keyframes are ignored
// by tween(), and `stagger` rides along for free.
heroClips.forEach((c) => heroTl.tween(c.target, c.keyframes, c));

// The playhead line rides the timeline's own 'update' event. Listener first:
// the driver seeks in its constructor, so subscribing after it would miss the
// bootstrap frame and leave the readout blank until the first scroll.
const heroTotal = heroTl.duration;   // 2200ms — the largest clip end
heroTl.on('update', (time) => {
  heroPlayhead.style.left = `${(time / heroTotal) * 100}%`;
  heroTime.textContent = `${Math.round(time)} ms`;
});

// Deliberately NOT the default range. 'top bottom' would put progress 0 at the
// moment the track first pokes above the fold, so most of the timeline would run
// while the stage is still half off-screen. Starting at the viewport's centre
// means nothing moves until you can actually see it, and the whole scrub then
// spans exactly the track's own height.
const heroDriver = scrollDriver(heroTl, {
  trigger: '#hero-track',
  start: 'top center',
  end: 'bottom center',
  onProgress: meter('sec-hero'),
});

// The ruler doubles as a scrub surface: pointer x maps to a time, seek() does
// the rest. Releasing snaps back to wherever scroll left the playhead — the
// driver never stopped tracking, so its progress IS the scroll position.
const heroScrub = (e) => {
  const rect = heroOverlay.getBoundingClientRect();
  const p = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
  heroTl.seek(p * heroTotal);
};
heroRuler.addEventListener('pointermove', heroScrub);
heroRuler.addEventListener('pointerleave', () => heroTl.progress(heroDriver.progress));

Scrub with your cursor

Move your cursor across the panel to move the playhead.

0 ms ▶ clock
view the hover driver + clip list
// A driver is just "pick a time, call seek". This reads clientX;
// nothing in Timeline knows the difference.
function hoverDriver(tl, el, { range = el, smoothing = 20, onOwner } = {}) {
  let target = 0;
  let holding = false;

  el.addEventListener('pointermove', (e) => {
    const rect = range.getBoundingClientRect();
    target = clamp01((e.clientX - rect.left) / rect.width) * tl.duration;
    if (!holding) {
      holding = true;
      tl.pause();
      onOwner?.(true);
    }
  });

  el.addEventListener('pointerleave', () => {
    if (!holding) return;
    holding = false;
    onOwner?.(false);
    tl.play(); // resumes from the current playhead
  });

  ticker.subscribe((delta) => {
    if (!holding) return;
    const k = 1 - Math.exp(-delta / smoothing);
    tl.seek(tl.time() + (target - tl.time()) * k);
  });
}

// One list builds the scrubbed timeline AND draws the strip.
const sigilClips = [
  { name: 'stars', target: '.fw-star', at: 0, duration: 2600, easing: 'linear', stagger: 28, keyframes: {…} },
  { name: 'clouds', target: '.fw-cloud', at: 0, duration: 3000, easing: 'linear', stagger: 90, keyframes: {…} },
];

const shellStarts = [0, 850, 1600];
shellStarts.forEach((at, i) => {
  const shell = `.fw-shell-${i + 1}`;
  const burstAt = at + 620;
  sigilClips.push(
    { name: 'trail', target: `${shell} .fw-trail`, at, duration: 620, easing: 'ease-out', keyframes: {…} },
    { name: 'flash', target: `${shell} .fw-flash`, at: burstAt, duration: 260, easing: 'ease-out', accent: true, keyframes: {…} },
    { name: 'sparks radial', target: `${shell} .fw-spark i`, at: burstAt, duration: 900, easing: 'ease-out', stagger: 7,
      keyframes: { 0: { '--r': '0px', '--spark-scale': '1', '--hue': rand(12, 320) },
                   100: { '--r': rand(96, 154, 'px'), '--spark-scale': '0.4' } } },
    { name: 'sparks fall', target: `${shell} .fw-spark`, at: burstAt, duration: 900, easing: 'ease-in', stagger: 7,
      // 0% is transparent because fill: 'both' holds it before the burst —
      // a lit 0% frame parks the sparks on the pad during the mortar climb.
      keyframes: { 0: { '--g': '0px', opacity: '0' }, 2: { opacity: '1' }, 72: { opacity: '0.82' },
                   100: { '--g': rand(56, 108, 'px'), opacity: '0' } } },
  );
});

const sigilTl = timeline({ defaults: { easing: 'ease-in-out' } });
sigilClips.forEach((c) => sigilTl.tween(c.target, c.keyframes, c));

const burstTimers = [0, 0, 0];
shellStarts.forEach((at, i) => {
  sigilTl.call(() => {
    const flash = $(`.fw-shell-${i + 1} .fw-flash`);
    flash.classList.add('called');
    clearTimeout(burstTimers[i]);
    burstTimers[i] = setTimeout(() => flash.classList.remove('called'), 180);
  }, { at: at + 620, direction: 1 });
});

hoverDriver(sigilTl, $('#studio'), {
  range: $('#studio-range'),
  onOwner: (isCursor) => {
    sigilOwner.textContent = isCursor ? '▮ cursor' : '▶ clock';
    sigilOwner.classList.toggle('is-cursor', isCursor);
  },
});
sigilTl.progress(0);   // paint the start state; nothing is written until this

viewTrigger('#sec-studio', {
  enter: () => sigilTl.play(),
  once: true,
  threshold: 0.35,
});

Looping timelines

Four loops of different lengths, so the fire never looks like it repeats.

four playheads · one ticker realign in 7h 29m 00s
view the four looping timelines
// Stagger is included in duration: clip duration + (count - 1) × stagger.
const flameClip = {
  name: 'flames', target: '.flame', at: 0, duration: 1580, easing: 'ease-in-out', stagger: 40,
  keyframes: {…},   // 1580 + 3 × 40 = 1700ms
};
const emberClip = {
  name: 'embers', target: '.ember', at: 0, duration: 1530, easing: 'ease-out', stagger: 70,
  keyframes: {…},   // 1530 + 11 × 70 = 2300ms
};
const pineClip = {
  name: 'pines', target: '.pine', at: 0, duration: 5000, easing: 'ease-in-out', stagger: 50,
  keyframes: {…},   // 5000 + 6 × 50 = 5300ms
};
const glowClip = {
  name: 'glow', target: '#camp-glow', at: 0, duration: 1300, easing: 'ease-in-out', stagger: 0,
  keyframes: {…},
};

const loopTimeline = (clip) => {
  const tl = timeline({ loop: true });
  tl.tween(clip.target, clip.keyframes, clip);
  return countTimeline(tl, 1);
};

const fireClips = [flameClip, emberClip, pineClip, glowClip];
const fireTimelines = fireClips.map(loopTimeline);

// One lane per timeline, all on ONE scale: track width is proportional to that
// timeline's own duration, so every playhead moves at the same px/second and
// only the wrap points differ. The drift on screen is the real drift.
const fireLongest = Math.max(...fireTimelines.map((tl) => tl.duration));

fireTimelines.forEach((tl, i) => {
  const { track, fill, head, lapOut } = drawLane(fireClips[i].name, tl.duration);
  track.style.setProperty('--loop-w', `${(tl.duration / fireLongest) * 100}%`);

  // A wrap is the only moment the playhead moves backwards.
  let laps = 0, previous = 0;
  tl.on('update', (time) => {
    if (time < previous) lapOut.textContent = `×${(laps += 1)}`;
    previous = time;
    const percent = `${(time / tl.duration) * 100}%`;
    head.style.left = percent;
    fill.style.width = percent;
  });
});

// The four periods only realign at their least common multiple — 26,939,900ms,
// which is what the countdown above the lanes is counting down.
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
const fireCycle = fireTimelines.reduce((a, tl) => (a * tl.duration) / gcd(a, tl.duration), 1);

fireTimelines.forEach((tl) => tl.progress(0));

const playFire = () => fireTimelines.forEach((tl) => tl.play());
const pauseFire = () => fireTimelines.forEach((tl) => tl.pause());

viewTrigger('#sec-fire', { enter: playFire, leave: pauseFire });

Stagger

One tween() on nine dominoes, each starting 300ms after the last.

driver progress 0.000
view the timeline()
// Geometry in domino widths — the CSS uses the same ratios.
const W = 1, H = 2, S = 1.625;   // width, height, pivot-to-pivot

// The angle a domino settles at while its neighbour leans at b: its top
// corner sits on the neighbour's face. lean(0) is the first touch, ~18°.
const lean = (b) => b + Math.asin((S * Math.cos(b) - W) / H);

// One fall in T-long segments: a flicked fall to the first touch, then each
// segment rides the neighbour — which is this same curve, one T behind.
const SEG = 5, T = 300;
const tip = (t) => (t <= 1 ? (lean(0) * (t + t * t)) / 2 : lean(tip(t - 1)));
const deg = (r) => (r * 180) / Math.PI;

const dominoFrames = {};
for (let p = 0; p <= 100; p += 2) {
  dominoFrames[p] = { '--tip': deg(tip((p / 100) * SEG)).toFixed(2) };
}

// CSS: .domino-tile { transform: rotate(calc(var(--tip) * var(--k) * 1deg)) }
// The last tile has nothing to lean on, so its --k stretches the fall to 90°.
dominoRow.lastElementChild.style.setProperty('--k', 90 / dominoFrames[100]['--tip']);

const dominoTl = timeline();
dominoTl.tween('.domino', dominoFrames, { duration: SEG * T, stagger: T, easing: 'linear' });

// duration → 1500 + 8 × 300 = 3900ms

// The stage is sticky at top:15vh and 62vh tall, so the range is written to
// match: progress 0 the moment it pins, 1 as it unpins.
scrollDriver(dominoTl, {
  trigger: '#stagger-track',
  start: 'top 15%',
  end: 'bottom 77%',
  onProgress: meter('sec-stagger'),
});

Trigger an animation at a time

Scroll to burn the fuse. At 'ignite', tl.call() plays the explosion.

    view the timeline() + call()
    // One path string feeds the SVG fuse and the spark's offset-path.
    const FUSE_D = 'M257 118 C 282 84, 266 46, 226 50 S 162 92, 118 70 S 66 10, 30 38';
    $$('.fuse-path').forEach((path) => path.setAttribute('d', FUSE_D));
    $('#fuse-spark').style.offsetPath = `path('${FUSE_D}')`;
    
    // The physics half: one blast scene, built once, replayed on every crossing.
    // (Springs are opt-in — registerPhysics(PhysicsEngine) ran once at page setup.)
    // puffs[i] and flight[i] are seeded per element when the markup is built.
    const boom = scene()
      .set('#blast > *', { opacity: '0' })   // clear a stopped run's leftovers first
      .parallel((b) => b
        .fromTo('.blast-flash', { opacity: '1', transform: 'scale(0.15)' },
          { opacity: '0', transform: 'scale(2)' }, { duration: 700, easing: 'ease-out' })
        .fromTo('.blast-fire', { opacity: '1', transform: 'scale(0.2)' },
          { opacity: '0', transform: 'scale(1.7)' }, { duration: 1000, easing: 'ease-out' })
        .fromTo('.blast-ring', { opacity: '1', transform: 'scale(0.2)' },
          { opacity: '0', transform: 'scale(3)' }, { duration: 820, easing: 'ease-out' })
        .stagger('.blast-smoke', (el, i) => ({
          from: { opacity: '0.9', transform: 'translate(0px, 0px) scale(0.3)' },
          to:   { opacity: '0',   transform: `translate(${puffs[i].x}px, ${puffs[i].y}px) scale(1.8)` },
          duration: 1800, easing: 'ease-out',
        }), { interval: 30 })
        .stagger('.debris', (el, i) => ({
          from: { opacity: '1', transform: 'translate(0px, 0px) rotate(0deg) scale(0.4)' },
          to:   { opacity: '1', transform: `translate(${flight[i].x}px, ${flight[i].y}px) rotate(${flight[i].spin}deg) scale(1)` },
          physics: { attraction: 0.03, friction: 0.11 },
        }), { interval: 4 }))
      // Steps are sequential: the debris fades once every spring has settled.
      .stagger('.debris', { to: { opacity: '0' }, duration: 380, easing: 'ease-out' }, { interval: 8 });
    
    // Scrubbed shiver as the fuse gets short.
    const shake = {
      0:   { transform: 'rotate(0deg) scale(1)' },
      20:  { transform: 'rotate(-1.5deg) scale(1.01)' },
      40:  { transform: 'rotate(2deg) scale(1.02)' },
      60:  { transform: 'rotate(-2.5deg) scale(1.03)' },
      80:  { transform: 'rotate(3deg) scale(1.05)' },
      100: { transform: 'rotate(0deg) scale(1.08)' },
    };
    
    // The scrubbed half. The rope is pathLength="100", so a dashoffset of 100 is
    // a fully burnt fuse; the spark's offset-path is the same path string.
    const fuseTl = timeline({ defaults: { easing: 'linear' } });
    
    fuseTl
      .tween('.fuse-rope', { 0: { strokeDashoffset: '0' }, 100: { strokeDashoffset: '100' } }, { duration: 1200 })
      .tween('#fuse-spark', { 0: { offsetDistance: '100%' }, 100: { offsetDistance: '0%' } }, { at: 0, duration: 1200 })
      .tween('#bomb-heat', { 0: { opacity: '0' }, 100: { opacity: '1' } }, { at: 500, duration: 700, easing: 'ease-in' })
      .tween('#bomb-body', shake, { at: 850, duration: 350 })
      .label('ignite')                              // names the current end: 1200ms
      // The timeline owns what shows: past 'ignite' the bomb is gone and the blast
      // layer is up; scroll back and both set()s un-apply, deterministically.
      .set('#bomb', { opacity: '0' }, { at: 'ignite' })
      .set('#blast', { opacity: '1' }, { at: 'ignite' })
      .tween('#bomb-scorch', { 0: { opacity: '0' }, 100: { opacity: '1' } }, { at: 'ignite', duration: 500, easing: 'ease-out' })
      .call((direction) => {
        logCrossing(direction);
        boom.stop();                              // a quick re-cross restarts it cleanly
        if (direction === 1) boom.play();          // forward crossings only
      }, { at: 'ignite' });                         // once: false — re-arms every pass
    
    // scrollDriver seeks non-silently by default, so scrubbing fires calls.
    scrollDriver(fuseTl, { trigger: '#fuse-track', start: 'top 15%', end: 'bottom 77%' });

    Locked vs. smoothed

    The left needle follows the scroll. The right one eases after it.

    Locked smoothing: 0
    0.000
    Smoothed smoothing: 150
    0.000
    scroll position 0.000
    view the timelines()
    // Same clip, twice — the only difference is the driver.
    const dial = (target) =>
      timeline().tween(target, {
        0:   { transform: 'rotate(-62deg)' },
        100: { transform: 'rotate(62deg)' },
      }, { duration: 1000, easing: 'linear' });
    
    const lockedTl = dial('#needle-locked');
    const smoothTl = dial('#needle-smooth');
    
    const range = { trigger: '#smooth-track', start: 'top 15%', end: 'bottom 77%' };
    
    scrollDriver(lockedTl, { ...range, smoothing: 0 });     // welded to scroll
    scrollDriver(smoothTl, { ...range, smoothing: 150,       // ms time constant
      onProgress: meter('sec-smooth') });                // onProgress reports the raw scroll
    
    // Read the two playheads back out for the numbers under each gauge.
    lockedTl.on('update', () => { lockedOut.textContent = lockedTl.progress().toFixed(3); });
    smoothTl.on('update', () => { smoothOut.textContent = smoothTl.progress().toFixed(3); });

    Play when scrolled into view

    viewTrigger() plays a spring scene when the bubbles scroll into view.

    waiting for the viewport…
    view the scene() + viewTrigger()
    // scene comes from timeline-engine's bundle — same ticker, one rAF loop.
    const { scene, viewTrigger, registerPhysics } = TimelineEngine;
    
    // Springs are opt-in as of animation-engine 0.2.0: register an implementation
    // once, or a { physics } step throws. PhysicsEngine is its own script tag.
    registerPhysics(PhysicsEngine);
    
    // Physics: no duration, no easing — a spring settles when it settles.
    const pop = scene().stagger('.pop-bubble', (el, i) => ({
      from: { opacity: '0', transform: 'translateY(56px) scale(0.35)' },
      to:   { opacity: '1', transform: 'translateY(0px) scale(1)' },
      physics: { attraction: 0.03, friction: 0.09 + i * 0.012 },  // looser on the left
    }), { interval: 90 });
    
    viewTrigger('#pop-stage', {
      enter: () => {
        pop.play();
        setPopStatus('entered view → scene playing (observer disconnected)');
      },
      once: true,
      threshold: 0.45,
    });
    
    $('#pop-replay').addEventListener('click', () => {
      pop.play();
      setPopStatus('replayed by hand');
    });

    Play on a clock

    play() runs the timeline on its own. The slider moves it by hand.

    0 / 0 ms
    scrub
    idle
    view the timeline() + transport
    const playTl = timeline({ defaults: { easing: 'ease-in-out' } });
    
    playTl.tween('.beat', {
      0:   { transform: 'scaleY(0.12)', opacity: '0.3' },
      100: { transform: 'scaleY(1)',    opacity: '1' },
    }, { duration: 600, stagger: 80, easing: 'back-out' });
    
    playTl.progress(0);   // paint the 0% frame — a timeline writes nothing until seeked
    
    // One 'update' listener feeds the bar, the readout and the slider.
    playTl.on('update', () => {
      const p = playTl.progress();
      playBar.style.transform = `scaleX(${p})`;
      playTime.textContent = `${Math.round(playTl.time())} / ${playTl.duration} ms`;
      playScrub.value = String(p);
    });
    
    $('#play-go').addEventListener('click', async () => {
      setPlayStatus('playing', true);
      await playTl.play();          // resolves on complete OR pause
      setPlayStatus(playTl.progress() >= 1 ? 'complete' : 'paused', false);
    });
    
    $('#play-pause').addEventListener('click', () => playTl.pause());
    
    playScrub.addEventListener('input', () => {
      if (playTl.playing) playTl.pause();
      playTl.progress(Number(playScrub.value));   // same clips, hand-driven
      setPlayStatus('scrubbing', false);
    });
    
    $$('[data-rate]').forEach((btn) => {
      btn.addEventListener('click', () => {
        playTl.timeScale(Number(btn.dataset.rate));
        $$('[data-rate]').forEach((b) => b.classList.toggle('active', b === btn));
      });
    });