Clips on one timeline
Seven clips with their own start times. Scroll to move the playhead, or drag the bars.
seven clips, one timeline
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.
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.
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.
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.
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.
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.
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));
});
});