Skip to content

Writing

A dead animation looks exactly like one that has not started

CSS scroll-driven animations fail silently in four different ways, and every one of them renders a page that looks fine. Here is how we made them fail loudly instead.

4 min read

Every animation on this site is driven by scroll position rather than by a clock, and none of it ships a line of JavaScript. That is a good trade. What nobody tells you is that the failure mode of a scroll-driven animation is not a broken page — it is a page that looks completely fine.

We hit four of them in two days. Each one took a while to find, and each one was invisible in the state anyone would check first.

The base rule is the finished state, and that is the problem

Reduced motion has to mean none, not faster. So every animation here is declared inside a media query, and the rule outside it is where the animation ends:

css
.ts-wave-item {
  opacity: 1;
}
 
@media (prefers-reduced-motion: no-preference) {
  .ts-wave-item {
    animation: ts-wave-in linear both;
    animation-timeline: --ts-wave;
  }
}

That is correct, and it is also why every failure below is silent. If the animation does not run, the element sits at its finished state — which is exactly what it should look like once the animation has run.

One: the keyframes did not exist

Two animations named keyframes that had been defined in a block someone later reverted. The references outlived the definitions.

An animation-name pointing at keyframes that do not exist does not warn, does not throw, and does not fail the build. It simply never runs. Four separate mechanisms were dead for a day and every one of them "rendered correctly".

The fix is not to be more careful. It is to make the machine check:

ts
/** Every animation must name keyframes that exist. */
const missing = [...referenced].filter((name) => !defined.has(name));
expect(missing, `these animations never run: ${missing.join(', ')}`).toEqual([]);

The second test guards the first. A regex that quietly stops matching would make the check pass forever, which is a worse outcome than not having it.

Two: the range ran backwards

animation-range takes a start and an end. Write them in the wrong order and the range is invalid — so the browser discards it and falls back to the default, which covers the whole passage. Every element in a stagger then fires at once.

Before

Ranges written descending. Every row lights simultaneously. Looks exactly like a stagger that was never applied.

After

Ranges ascending. The rows arrive in order, which is what the code always said it wanted.

Three: the range was on the wrong element

animation-range does not inherit. Setting it on a parent while the child carries the animation produces the same symptom as the last one — everything in unison — from a completely different cause. That cost a second debugging pass, because the first fix had trained us to read the symptom as a range problem.

Four: the timeline was measured against a box that never moves

This is the interesting one.

A table on this site scrolls sideways inside its own container. overflow-x: auto makes overflow-y compute to auto as well, so that container is a scroll container on both axes. A view timeline is measured against its nearest scrollport ancestor — so a stagger declared inside it was measured against a box that never scrolls.

Progress froze at one value. Forever. The first three rows landed past their range and the fourth landed inside it, and sat at fifteen percent opacity looking like a row that had failed to load.

Why the fix is not `overflow: clip`

It would work, and it would also stop the table scrolling, which is the one thing the container exists to do. The timeline moved to a wrapper outside the container instead. Named view timelines resolve up the ancestor chain by tree scope, not by scroll container, so the rows still find it.

What we changed about how we work

Not "be more careful". Three things that hold without anyone remembering them:

  1. Assert the keyframes exist

    A test parses the stylesheet and fails the build on any animation whose keyframes are missing. It has already caught the mistake twice.

  2. Probe the middle, not the ends

    Every mechanism is verified by scrolling a real browser through the page and reading computed opacity and transform at a dozen positions. Both endpoints can be right while nothing in between moves.

  3. Check the reduced-motion rest state separately

    The base rule has to be the finished state. A test asserts that nothing on the page sits below full opacity when motion is off — which is how the inverse bug, shipping at opacity: 0, gets caught.

None of this is exotic. It is the same lesson every silent failure teaches: if the only way to notice is to look at the right moment, you will not notice.

Start here

Tell us what you are building

Or what is breaking, or what has to go faster. You will get a straight answer from an engineer who would do the work.