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:
.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:
/** 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
After
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:
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.
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.
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.