Two browser panels compare the same heading reveal at full scroll: every character tile is in place on the left, and the last few are still faded on the right.

Scrubbed text animations that never reached their last characters

Moving my Drupal Canvas motion module onto the Web Animations API: five bugs a screenshot parity pass caught, and why currentTime includes the delay.

Scroll to the bottom of a character-reveal heading and every character should be in place. Under the new engine, the trailing characters were still sitting at their from-state at 100% scroll, waiting for a position the page did not have. The same preset under the old engine finished cleanly.

That was the worst of five bugs a screenshot parity pass turned up on August 31 and September 1, while I moved canvas_builder_motion (my motion module for Drupal Canvas) off a third-party animation library and onto a native engine built on the Web Animations API (WAAPI), using web standards alone: Element.animate(), IntersectionObserver, matchMedia and Intl.Segmenter. All five came from the same place. Once you drive animations by hand, the timing and layout bookkeeping a library does for you becomes your code, and a mistake there still animates. It just stops in the wrong place.

A seam before the engine

Before writing any WAAPI code, I routed the runtime through a swappable engine adapter, with the existing library as the first adapter. The contract in engine/contract.js is a short list of engine-neutral calls (animate, sequence, scrollBind, hold, textSegments, strokeDraw, dispose), and its only input is a resolved spec: plain data that spec.js builds from the stored motion values, with property names like opacity and x and nothing engine-specific.

The native engine landed the same evening as engine/waapi.js, along with a site setting to pick the engine and a switch to Lenis for smooth scrolling. A Vitest conformance suite pushed the same specs through both engines against one fixture DOM: start and end styles, stagger offsets, split fragment counts, and a dispose() that leaves the element byte-identical.

That suite had no test for pinning and none for a staggered scrub’s total duration. Three of the five bugs lived there.

The harness: one page per effect, two engines

The parity scripts live in tests/src/Playwright/parity/. build-harness.mjs reads the preset catalog from the live site through drush php:eval on every run, so nobody hand-copies preset values. capture.mjs builds an isolated page for each preset, loads it once per engine, scrolls the effect into view, and takes two viewport screenshots at 1280 by 900:

tests/src/Playwright/parity/capture.mjs
await page.goto(`http://127.0.0.1:${port}/?engine=${engine}`, {
waitUntil: 'load',
});
await page.evaluate(() => window.__parityReady);
await page.waitForTimeout(300);
const locator = page.locator(`[data-component-uuid="${target.uuid}"]`);
await locator.scrollIntoViewIfNeeded();
await page.mouse.wheel(0, 150);
await page.waitForTimeout(250);
await page.screenshot({ path: resolve(outDir, `${target.uuid}-a.png`) });
await page.waitForTimeout(900);
await page.screenshot({ path: resolve(outDir, `${target.uuid}-b.png`) });

The first version put every preset on one long page, and that was its own bug. The two engines’ pin spacers differ by a few pixels, and twenty stacked sections compound that until a fixed scroll target lands on two different points of two different pages. A fresh page per effect makes every capture’s scroll math start from zero.

The comparison is visual: the two engines’ screenshots side by side, judged on start state, end state and overall shape. A scrubbed effect’s mid-scroll frame can sit a few percent ahead or behind between engines, because each engine smooths scroll progress its own way, so a mid-scroll frame was never going to match pixel for pixel.

Pins in the wrong place, at the wrong time

The first two bugs broke pinned-reveal and horizontal-track. pinRange() measured the pinned element’s rect once, at page load, and reused it when the pin engaged. A pin far down a long page got position: fixed at its unscrolled offset, thousands of pixels off screen. The fix calls getBoundingClientRect() at the moment pinning engages.

The second was a clamp. scrollProgress() folds any position before the trigger’s start down to 0, the same value as exactly at the start, so the check progress >= 0 was always true and the pin engaged on the scroll listener’s first synchronous call at page load. A new withinScrollRange() compares the raw scroll position against the range instead.

The timeline was shorter than its last target

A staggered step finishes when its last target does. The sequence recorded each step’s length as its duration plus any repeat delay, ignoring how far the stagger pushed later targets out:

engine/waapi.js
const maxOffset = offsets.length > 0 ? Math.max(...offsets) : 0;
toSteps.push({
targets: list,
players,
position: numberOr(position, 0),
durationSec: durationSec + repeatDelaySec,
durationSec: delaySec + maxOffset + durationSec + repeatDelaySec,
});

A scrub maps scroll onto seek(progress * totalDuration()). With the total too short, a target whose own delay sat past that end could never be reached at any scroll position, including 100%.

currentTime includes the delay

This is the bug from the opening, and the one worth knowing if you build on WAAPI. Each split character is its own Animation, with its stagger expressed as that animation’s delay. The engine assigned currentTime the active-phase value, clamped between 0 and the duration. But currentTime counts from the start of the animation, delay phase included, and an effect stays in its before phase until that time passes its delay. Any character whose delay was larger than the clamped value never left the before phase, and with fill: 'both' it held its from-state forever.

engine/waapi.js
const localMs = (t - s.position) * 1000 - p.delayMs;
p.animation.currentTime = Math.min(
p.totalDurationMs,
Math.max(0, localMs),
);
p.animation.currentTime =
p.delayMs + Math.min(p.totalDurationMs, Math.max(0, localMs));

The same fix went into wireScrub(), which also needed the previous bug’s treatment: scroll progress now maps onto the whole group’s length, the largest delay plus duration across its players, instead of one player’s own duration. char-reveal-scrub and text-rise-scrub were hit hardest, with many targets and a small per-target stagger that adds up across a heading.

Punctuation on its own baseline

The splitter uses Intl.Segmenter with word granularity, which returns words, whitespace and punctuation as separate segments and marks only words with isWordLike. Words became display: inline-block spans. Everything else went in as a bare text node.

Whitespace has to be a bare text node, because an inline-block collapses a space to nothing. Punctuation in a bare text node sits in a different inline layout context from its inline-block neighbours, so a comma or full stop could render on an off baseline, reading as a raised apostrophe or a detached dot. The fix gives punctuation its own inline-block span and keeps it out of the animated set:

engine/waapi.js
if (/^\s+$/.test(segment)) {
container.appendChild(doc.createTextNode(segment));
} else {
const span = doc.createElement('span');
span.style.display = 'inline-block';
span.textContent = segment;
container.appendChild(span);
}

That put punctuation back on the shared baseline.

The harness had two bugs of its own. The test label sat inside the animated wrapper, so a text split animated the label’s words too. And the test content put children directly under the wrapper with no row container, which collapsed horizontal-track’s track lookup onto the wrapper itself. Real sections nest their columns, and the harness now does as well.

Deleting the adapter

With those five fixed, all 18 presets matched under both engines, plus load and click samples for three of them and a section timeline that arms two animations on one element. On September 1 I deleted the old adapter and its library dependency, the native engine became the only one shipped, and the commit removed roughly 2,800 lines while adding about 400. engine/waapi.js was 1,545 lines at that point.

Reduced motion stayed with the connector, ahead of any engine. On the default strategy a visitor who prefers reduced motion gets no text splitting, no pins, no horizontal travel and no repeats: the effect runs as a plain fade on the unsplit element.

Two things the pass did not cover. Lenis’s smooth scrolling was not part of the screenshot comparison. And the splitter rebuilt each element from its flat textContent, so a <strong> or a link inside split text came out as plain text with its href gone.

The open part

The engine still drives currentTime from JavaScript on every scroll frame. Native scroll-driven animations, through ScrollTimeline, would hand progress to the browser and take the scroll handler out of the loop. I have not worked out how to express a staggered group on one shared scroll range that way without duplicating the range per character, and the shared group length is exactly what bugs three and four were about.

If you have done stagger and delay on a ScrollTimeline with a fallback that keeps one preset format, I want to see it.