Blog Engineering
A Scroll Reveal That Could Never Fire
While building the motion on this site I wrote a scroll reveal, the way most
sites do: hide an element, watch it with an IntersectionObserver, add a class
when it comes into view.
Everything below the fold stayed invisible. Not sometimes — always, no matter how far you scrolled.
The setup
Rather than the usual fade-and-slide, I wanted content to be uncovered: a clip edge travelling across it, so it reads as a window opening instead of a block floating in.
[data-reveal] {
clip-path: inset(0% 0% 100% 0%); /* clipped to nothing */
transition: clip-path 900ms;
}
[data-reveal].is-revealed {
clip-path: inset(-40% -40% -40% -40%);
}
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("is-revealed");
io.unobserve(entry.target);
}
}, { threshold: 0.12 });
Ordinary code. It had worked for me before with opacity.
Finding it
My first three guesses were all wrong, and all plausible: that the negative
inset in the revealed state was invalid CSS and being dropped, that the
0.12 threshold was too high for tall elements, that the scoped-style
attribute wasn’t matching the elements the selector expected.
So I stopped guessing and put three boxes on a page — one clipped, one hidden
with opacity: 0, one plain — all sitting in the viewport at load, all
observed by the same observer, and printed what the callback actually received:
c [clipped] isIntersecting=false ratio=0.000
o [opacity] isIntersecting=true ratio=0.726
p [plain] isIntersecting=false ratio=0.000
There it is. Two boxes in the same place, differing only in how they were hidden, and the observer sees one of them and not the other.
clip-path reduces the intersection rectangle to nothing, so the ratio is
exactly 0. opacity: 0 does not — an invisible element still occupies its
full box as far as the observer is concerned.
(The third box reports 0 legitimately: it had been pushed below the fold by the other two.)
Why it’s a deadlock, not a bug
Once you see it, the shape of the problem is worse than a wrong number.
The element hides itself in the one way that guarantees it can never be told to come back. The hidden state and the trigger condition are in direct conflict: the observer will fire when the element is visible, and the element has made itself un-visible to the observer. There is no scroll position that resolves it. It is stuck forever.
That is the part worth remembering. A bug gives you a wrong answer; this gave me a state machine with no exit.
The fix
getBoundingClientRect() is unaffected by clip-path — it reports the real
layout box of a clipped element. So the visibility test moves off the observer
and onto geometry:
const check = () => {
const vh = window.innerHeight;
for (const el of Array.from(pending)) {
const r = el.getBoundingClientRect();
if (r.top < vh * 0.88 && r.bottom > 0) {
el.classList.add("is-revealed");
pending.delete(el);
}
}
if (!pending.size) teardown();
};
rAF-throttled on scroll, elements leave the set once revealed, and the
listeners detach when it empties. The teardown also reveals anything still
pending, so no failure path can leave content clipped.
The rule I took from it
Never let an element’s hidden state be the thing that decides whether it can
be shown. Anything that hides by removing geometry — clip-path,
display: none, zero height — is invisible to a geometry-based observer too.
opacity and transform are safe because they change what is painted, not
what is measured.
The postscript
I fixed it, looked at the result, and then deleted the whole system anyway.
With the reveals working, scrolling the site looked like the page was still loading — content arriving late reads as slow, not as designed. So the reveals came out and the motion budget went to things the reader actually causes: the hero that types itself, counters that tick up, hover states, page transitions.
Which is the more useful lesson, in the end. I spent a while debugging an animation that shouldn’t have existed. Worth knowing why it broke; worth more knowing it wasn’t worth having.