A motion-blurred timeline streak against a dark ground.

Delay arithmetic: what I got wrong about scroll-driven timelines

How scroll-scrubbed motion behaves when you drive currentTime by hand, and the parity harness that caught three bugs I could not see.

A scroll-scrubbed reveal was landing early. Not badly early — the element hit its final state about 200ms of timeline before the scroll range ran out, then sat there while the user kept scrolling. Press play on the same preset as a normal entrance animation and it was perfect. Scrub it and it lied.

That gap was the delay. And finding it is the moment I understood what I had actually signed up for when I put a second motion engine behind canvas_builder (Canvas Builder).

The module has driven motion through a GSAP adapter since I started it. GSAP is excellent and I co-maintain the gsap (GSAP/Greensock) Drupal integration, so this is not a post about escaping it. The question was narrower: for a site whose motion vocabulary is entrance animations and simple scrubs, is a timeline library a dependency worth shipping to every visitor? The only honest way to answer that was to build the alternative and compare them under the same presets.

Two engines need one source of truth

The first move had nothing to do with animation. The runtime had grown a habit of reading configuration in the same place it built tweens — preset name in, GSAP call out. With a second engine coming, that had to split.

So config resolution became a pure function. Preset plus editor overrides in, a normalised spec out, no DOM and no engine anywhere near it:

js/motion/resolve.js
export function resolveMotion(config) {
const preset = PRESETS[config.preset] ?? PRESETS.fade;
return {
split: config.split ?? preset.split ?? 'none', // 'none' | 'words' | 'chars'
keyframes: preset.keyframes,
timing: {
duration: config.duration ?? preset.duration,
delay: config.delay ?? 0,
stagger: config.stagger ?? preset.stagger ?? 0,
easing: config.easing ?? preset.easing ?? 'ease-out',
},
};
}

Everything downstream — the GSAP adapter, the native engine, the tests — consumes that object. Which meant that when the two engines disagreed, the disagreement was provably in the engine and not in how each one had interpreted the editor’s settings. That turned out to matter more than the refactor itself.

Scrubbing is just setting currentTime

The native engine is smaller than I expected. Element.animate() returns an Animation, an Animation has a writable currentTime, and a paused animation with fill: 'both' will render whatever time you hand it. That is scrubbing.

js/motion/engine-waapi.js
export function createTimeline(el, spec) {
const targets = spec.split === 'none' ? [el] : splitText(el, spec.split);
const animations = targets.map((node, i) => {
const anim = node.animate(spec.keyframes, {
duration: spec.timing.duration,
delay: spec.timing.delay + i * spec.timing.stagger,
easing: spec.timing.easing,
fill: 'both',
});
anim.pause();
return anim;
});
const total = Math.max(...animations.map((a) => {
const t = a.effect.getComputedTiming();
return t.delay + t.activeDuration + t.endDelay;
}));
return {
total,
seek(progress) {
const time = Math.min(Math.max(progress, 0), 1) * total;
for (const a of animations) a.currentTime = time;
},
play() {
for (const a of animations) { a.currentTime = 0; a.play(); }
},
};
}

Element.animate() starts playing immediately, so the pause() is load-bearing rather than tidy. A ScrollTrigger-shaped observer calls seek() on each frame with a normalised progress value, and the scroll handler never needs to know how many children the effect has.

That total computation is the part I got wrong.

Where delay stops being free

My first version was one line shorter:

seek(progress) {
const time = progress * spec.timing.duration;
const time = Math.min(Math.max(progress, 0), 1) * total;
for (const a of animations) a.currentTime = time;
},

With a 200ms delay and an 800ms duration, scrolling to the end of the range set currentTime to 800 — which is 200ms past the end of an effect that finishes at 1000. Playback was fine because playback lets the clock run. Scrubbing was wrong because scrubbing means I own the clock, and I had been computing the duration of one child while pretending it was the duration of the timeline.

This is the whole trade, stated plainly. A timeline library does duration arithmetic for you: it knows that a delay extends the envelope, that a stagger of n children extends it by (n-1) × stagger, that a playbackRate of 0.5 doubles the wall-clock length without touching the effect’s internal timing. Drop the library and that bookkeeping does not disappear — it becomes yours, and it becomes yours in the least visible place, where a mistake still animates beautifully and just ends at the wrong moment.

The stagger case bit me a second time for the same reason. Six characters at 60ms apart add 300ms to a timeline that the preset says is 800ms long. getComputedTiming() on every child and take the max — cheap, and correct even when a future preset gives children different durations.

The harness: same preset, two engines, one diff

At this point I stopped trusting my eyes. Two engines that agree at progress 0 and progress 1 and disagree in the middle look identical in a screen recording, and I had just proved I would ship that.

So the parity harness drives both engines through the same resolved spec, seeks each to a fixed set of progress values, and reads back computed styles:

tests/motion/parity.js
const STEPS = [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1];
const TOLERANCE = 0.5; // px, and unitless for opacity × 100
async function sampleEngine(page, engine, preset) {
return page.evaluate(async ({ engine, preset, STEPS }) => {
const el = document.querySelector('[data-motion-target]');
const spec = window.motion.resolveMotion({ preset });
const tl = window.motion.engines[engine].createTimeline(el, spec);
const frames = [];
for (const p of STEPS) {
tl.seek(p);
await new Promise(requestAnimationFrame);
frames.push([...el.querySelectorAll('[data-motion-part]'), el].map((n) => {
const cs = getComputedStyle(n);
return { transform: cs.transform, opacity: Number(cs.opacity) };
}));
}
return frames;
}, { engine, preset, STEPS });
}

Comparing matrix() strings component by component with a half-pixel tolerance turns “does it look right” into a list of numbers with a row number attached. A screenshot diff at the same steps backs it up for anything the computed styles hide — clipping, blend modes, a wrap that only happens at one viewport width.

Three defects came out of the first run. The delay envelope. The stagger envelope. And one I would never have found by watching.

Punctuation, baselines, and a split I had to rewrite

Character-split text was drifting by roughly a pixel vertically against the GSAP engine, and only on some lines.

My splitter was doing the obvious thing — textContent.split(''), wrap each character in an inline-block span, animate the spans. Two problems, both mine. Graphemes composed of multiple code units came apart, so an accented character and an emoji each became two boxes. And because every character was now its own inline-block, the browser was free to break a line before a full stop, which pushed one line’s baseline and produced exactly the sub-pixel drift the harness was flagging.

The fix is a two-level split. Words own line breaking, characters own the transform:

js/motion/split.js
export function splitText(el, granularity) {
const lang = document.documentElement.lang || 'en';
const words = new Intl.Segmenter(lang, { granularity: 'word' });
const chars = new Intl.Segmenter(lang, { granularity: 'grapheme' });
const source = el.textContent;
const parts = [];
el.textContent = '';
for (const { segment } of words.segment(source)) {
const word = document.createElement('span');
word.style.cssText = 'display:inline-block;white-space:pre;';
if (granularity === 'words') {
word.textContent = segment;
word.dataset.motionPart = '';
parts.push(word);
}
else {
for (const { segment: g } of chars.segment(segment)) {
const char = document.createElement('span');
char.style.cssText = 'display:inline-block;';
char.dataset.motionPart = '';
char.textContent = g;
word.append(char);
parts.push(char);
}
}
el.append(word);
}
return parts;
}

Punctuation now travels inside the word segment it belongs to, which is what Intl.Segmenter word granularity gives you for free.

The constraint I accepted rather than solved: this only runs on elements whose children are a single text node. Nested markup — a link or an <em> inside the heading — makes the engine skip splitting and animate the whole element instead. Rebuilding arbitrary inline trees around per-character wrappers is a real piece of work and I have not done it. The editor sees a note in the motion settings rather than a silently different result.

What I would still reach for GSAP to do

Pinning. Anything where the element leaves normal flow, holds position while the page scrolls past, and hands off to the next section. Timelines with relative labels, where step three starts 100ms before step two ends and you want to edit that number without recomputing four delays. Morphing, motion paths, and the parts of the plugin ecosystem that exist because someone already solved the awkward maths.

What the native engine now covers is the traffic: entrance animations, reveal-on-scroll, staggered text, simple scrubs — the vocabulary an editorial team actually reaches for on a content page. For those, the runtime is a few kilobytes of bookkeeping instead of a library, the reduced-motion path is a single seek(1), and the animations are real Animation objects that any other script can find with getAnimations().

The unresolved part is whether the JavaScript should be driving currentTime at all. Native scroll-driven animations let the compositor own the progress value and take the scroll handler out of the loop entirely — but I have not worked out how to express a stagger through a scroll-linked timeline without either duplicating the range per child or giving up the shared envelope that made the delay bug findable in the first place. If someone has landed that cleanly, particularly with a fallback that does not fork the preset format, I would like to see it.

Next for canvas_builder is the layer above this: how a motion preset gets exposed to an editor in Canvas as a governed choice rather than a pile of numbers, and whether the parity harness can be pointed at that config surface instead of at the engines.