@magic-spells/timeline-engine

Timeline
Engine

A bunch of frame engines with offset start times. The whole timeline is a pure function of time, so anything can own the playhead — the clock, a scrub position, or the scroll bar.

The scrubbed path is bezier and easings only: a spring has no closed-form position-at-time, so it can't be sampled at an arbitrary playhead. Physics lives on the trigger path — fire an animation-engine scene from a timeline position or from the viewport and it plays forward in real time.

Scroll to scrub
Install npm i @magic-spells/timeline-engine
Size (gzip) 5.8 kB esm · 14.6 kB umd
Uses

Scrub

Deterministic clips — tween(), fromTo(), set() — sampled at any millisecond by seek(), progress() or a scrollDriver. Same time in, same frame out, forwards or backwards.

Trigger

Springs, staggers and async chains from @magic-spells/animation-engine, started by tl.call() at a timeline position or by viewTrigger() when a section arrives. They play forward, in real time.

scroll · scrub

Clips at offset start times

A star, three orbits, a comet and a staggered sky — seven clips on one time axis, each with its own at and duration, deliberately overlapping. A scrollDriver maps this stage's trip through the viewport onto the playhead, so scrolling is scrubbing: run it backwards and the planets orbit backwards. The ruler underneath is drawn from the very same clip list; the white line is the live playhead — hover or drag it to scrub by hand, and when you let go it snaps back to wherever the scroll left it.

one axis, many starts

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));
scroll · scrub

One clip, nine start times

A single tween() over a nine-element selector with stagger: 150. Each element gets the same keyframes on its own window, offset by index × 150ms, and the timeline's duration stretches to cover the last one. Outside its window a clip holds its edge frames (fill: 'both'), which is why cards sit patiently at their 0% state until their turn arrives — and stay put afterwards.

driver progress 0.000
view the timeline()
const cardsTl = timeline();

cardsTl.tween('.stagger-card', {
  0:   { opacity: '0', transform: 'translateY(70px) rotate(-8deg) scale(0.86)' },
  100: { opacity: '1', transform: 'translateY(0px) rotate(0deg) scale(1)' },
}, { duration: 700, stagger: 150, easing: 'back-out' });

// duration → 700 + 8 × 150 = 1900ms

// 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(cardsTl, {
  trigger: '#stagger-track',
  start: 'top 15%',
  end: 'bottom 77%',
  onProgress: meter('sec-stagger'),
});
scroll · scrub + physics

A scene fired from a timeline position

The spark is scrubbed; the burst is not. tl.call() registers a callback at a labelled time — here 'ignite', the end of the spark's clip — and it fires when the playhead crosses it in either direction, scrubbing included. Scroll down past the mark to see direction +1 launch a spring-driven burst; scroll back up and the same crossing reports -1, which the callback ignores. This is the bridge between the two halves of the ecosystem.

ignite · 1200ms
    view the timeline() + call()
    // The physics half: a spring burst, built once, replayed on every crossing.
    // (Springs are opt-in — registerPhysics(PhysicsEngine) ran once at page setup.)
    const burst = scene()
      .stagger(shards, (el, i) => {
        const angle = (i / shards.length) * Math.PI * 2;
        return {
          from: { opacity: '1', transform: 'translate(0px, 0px) scale(0.3)' },
          to: {
            opacity: '1',
            transform: `translate(${Math.cos(angle) * 200}px, ${Math.sin(angle) * 120}px) scale(1)`,
          },
          physics: { attraction: 0.018, friction: 0.12 },
        };
      }, { interval: 14 })
      // Scene steps are sequential: this waits for every spring to settle.
      .stagger(shards, { to: { opacity: '0' }, duration: 320, easing: 'ease-out' }, { interval: 12 });
    
    // The scrubbed half.
    const fuseTl = timeline({ defaults: { easing: 'linear' } });
    
    fuseTl
      .tween('#fuse-spark', { 0: { left: '8%' }, 100: { left: '50%' } }, { duration: 1200 })
      .label('ignite')                        // names the current end: 1200ms
      .tween('#fuse-flash', {
        0:   { opacity: '0', transform: 'scale(0.35)' },
        100: { opacity: '1', transform: 'scale(1)' },
      }, { at: 'ignite', duration: 450, easing: 'ease-out' })
      .call((direction) => {
        logCrossing(direction);
        if (direction === 1) burst.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%' });
    hover · scrub · play

    Your cursor is the playhead

    No scroll and no physics here — cursor X is the playhead, so sweep backwards and every firework implodes while its mortar drops back to earth. The strip below is drawn from the same clip list that built the timeline, lit where a clip is actually interpolating and dimmed where it's only holding a fill frame. Pull the cursor away and play() picks it up from exactly where you left it.

    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,
    });
    clock · loop

    A timeline that never ends

    A single loop eventually gives itself away. This fire is four loop: true timelines on the shared ticker — flames at 1700ms, embers at 2300ms, pines at 5300ms and glow at 1300ms. Their coprime rhythms only realign after roughly 7.5 hours, so the composite never visibly repeats. The lanes underneath draw all four to one shared scale — same pixels per second, different wrap points — so you can watch them slide out of phase. Leaving the viewport pauses all four; returning resumes them from their own playheads.

    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 });
    scroll · scrub

    Locked vs. smoothed

    Two identical timelines, one scroll range, two drivers. The left needle is welded to the scroll position; the right one lerps toward it with smoothing: 150 — a time constant in milliseconds, so it closes ~63% of the gap every 150ms regardless of frame rate. Flick the page and watch the amber needle trail the teal one, then settle. Smoothing rides the shared ticker and unsubscribes the moment it catches up.

    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); });
    viewport · physics

    Triggered, not scrubbed

    Nothing here is on a playhead. viewTrigger is an IntersectionObserver wrapper: when the stage enters the viewport it plays an animation-engine scene — springs, one per bubble, each with its own friction, settling on their own schedule. That's precisely what a scrubbed clip can't express: a spring has no position-at-time to sample. once: true disconnects the observer after the first entry, so the button below is the only encore.

    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 · scrub

    The same timeline, on a clock

    Nothing about a timeline assumes scroll. play() rides animation-engine's shared ticker and returns a promise that resolves on completion or pause; timeScale() composes with the ticker's own. Drag the slider and the very same clips are driven by progress(p) instead — the playhead doesn't care who is moving it.

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