Skip to content
Velaris

Building in public

The scroll animation that hid our content permanently

A reveal effect and the observer meant to trigger it deadlocked each other. The bug was invisible in dev, total in production, and obvious in hindsight.

Vithu ·

We added scroll reveals to the marketing site — headlines that wipe into view as you reach them, the standard modern-site effect. The implementation is textbook: clip the text with clip-path, watch the element with an IntersectionObserver, remove the clip when it enters the viewport.

It worked. Then on one page, the headline never appeared. Not late — never. And the reason turned out to be a deadlock between the two halves of the effect.

The mechanism

IntersectionObserver decides whether an element is intersecting the viewport using its bounding rectangle. clip-path with a fully-collapsed shape gives an element a zero-area rect.

So: the element is clipped to nothing, which means it has no area, which means the observer never reports it as intersecting, which means the callback never runs, which means the clip is never removed. Each half is waiting on the other. The element sits there, present in the DOM, occupying layout, rendering nothing, forever.

Nothing errors. No console warning. The observer is attached and healthy; it simply has no reason to fire.

Why it didn’t show up sooner

This is the part worth dwelling on, because the bug was in the code from the first commit and only surfaced on one page.

On most pages the reveal targets sat inside a container that was itself in view on load, and the initial observer callback fires once at registration for elements already intersecting — before the clip had been applied by the stylesheet. The race resolved in our favour by accident.

On the page that broke, the target was below the fold. The clip landed first, the rect went to zero, and there was never a moment where the element was both visible and unclipped. The bug wasn’t intermittent — it was deterministic, and the determinism just happened to point the other way everywhere else.

A bug that depends on which side of a race you land on is not a rare bug. It’s a certain bug in the conditions that trigger it, and those conditions arrive on their own schedule.

The fix, and the better fix

The immediate fix was scoping: nested elements inside a reveal container were being clipped independently, so the selector was narrowed to exclude reveal targets that were themselves inside a reveal.

.js .reveal .label-mono:not(.reveal) {
  /* … */
}

But the more important change is the .js prefix, and it addresses a failure mode the observer bug only hinted at.

The failure that would have been worse

While fixing this I asked a question I should have asked when writing it: what does this page look like if the JavaScript never runs?

The answer was: blank. The clipping lived in the stylesheet. The unclipping lived in a script. If the script failed to load — CDN hiccup, a parse error in an unrelated bundle, a crawler that doesn’t execute JS, a user on a locked-down network — every animated headline on the site stayed invisible. Not degraded. Gone.

That’s a content-visibility bug affecting search engines and real people, dressed up as a motion effect. It had been shipped and nobody noticed, because everyone testing it had working JavaScript.

The fix is one line in the document head, before any stylesheet:

document.documentElement.classList.add("js");

Every reveal rule is now gated behind .js. No JavaScript, no clipping, content visible. The animation is an enhancement layered on top of a page that works without it, rather than a page that only works if the enhancement succeeds.

What I’d generalise

Three things, in increasing order of how often I’ve since needed them.

Effects that hide content should fail open. If your mechanism for showing something depends on code running, the default state must be shown. Invert it and every failure in the chain becomes a blank page.

Watch for circular dependencies between CSS and JS. They don’t look like circular dependencies because they live in different files and different languages. This one is clip-path → zero rect → no intersection → no unclip. Anything where a style affects geometry and a script observes geometry can close the same loop. visibility: hidden and display: none have the same property; opacity and transforms do not, which is why the fashionable fade-up-with-translateY pattern doesn’t hit this.

Test with JavaScript disabled, once. It takes a minute and it finds the class of bug where your site technically works and shows nothing. It’s the same instinct as checking what a crawler actually receives — assume the thing you rely on didn’t happen, and look.

The honest summary

I wrote an animation whose hiding half was more reliable than its showing half. That’s the whole bug, and it’s a design error rather than a coding one — no amount of careful implementation of that arrangement would have been safe.

The version that’s shipping now can’t fail the same way. If the script doesn’t run, you get a site with no animations, which is a site. That’s the bar: the failure mode of a decoration should be a missing decoration.

See also: debugging things you can see, where I made the opposite mistake and trusted my eyes over a measurement.