The Audit Verdict
An audit of this blog came back with a line that stops you cold: you are shipping a blog post that readers literally cannot read.
Not slow. Not ugly. Not low-contrast. Gone. The longest post on the site — thirty-five paragraphs, a full case study — rendered its title, its subtitle, its tags, and then nothing. A blank column where two thousand words should be. The HTML was all there. The text was in the DOM, selectable, correct. It was just painted at opacity: 0, top to bottom, and it never came back.
The worst part: it had been live for days, and three of the four posts on the site were fine. Only the long one was broken. That asymmetry is the whole story — it's why the bug shipped, why no test caught it, and why the fix is more interesting than "set opacity to 1."
The Pattern That Did It
Every post body on this site was wrapped in a single scroll-reveal component. You've seen the pattern a hundred times, probably written it yourself: content starts slightly lower and fully transparent, then fades and slides up into place as it scrolls into view. AOS, framer-motion's whileInView, a dozen homegrown hooks — they all do the same thing. It's the default texture of a modern marketing site.
The implementation is small. An element starts hidden:
const [shown, setShown] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setShown(true);
io.disconnect();
break;
}
}
},
{ threshold: 0.15 },
);
io.observe(ref.current);
return () => io.disconnect();
}, []);
// opacity: shown ? 1 : 0Read it and it looks obviously correct. The observer watches the element; when it scrolls into view, isIntersecting flips true, the element reveals, the observer disconnects so it never runs again. Ship it.
There's a bug hiding in a single number, and it only bites when the element is bigger than the screen.
Why threshold: 0.15 Is a Trap for Long Content
threshold: 0.15 tells the observer: fire when 15% of the target element is visible inside the viewport. For a card, a hero, a testimonial — anything smaller than the screen — that's fine. A 400px card in a 900px viewport can easily show 100% of itself, so it sails past 15% the moment it scrolls up.
Now make the element taller than the viewport. The "fraction of the element that's visible" is bounded by a hard ceiling: the viewport can only ever show viewportHeight / elementHeight of it at once. The blog body in question was 7,932 pixels tall. In a 900px-tall window, the most of itself it can ever display at one time is:
900 / 7932 ≈ 0.113 → 11.3%
11.3% is less than 15%. The threshold is never crossed. isIntersecting never reports the transition the code is waiting for. setShown(true) never runs. The body sits at opacity: 0 forever, no matter how far you scroll, because there is no scroll position anywhere on the page where 15% of a 7,932px element fits inside a 900px window.
The shorter posts were around 4,000px. 900 / 4000 ≈ 0.225 — 22.5%, comfortably past the 15% line. They revealed. They looked perfect. They made the bug invisible to me, to code review, and to every smoke test that loaded a post and checked for a heading. The one post long enough to trip the ceiling was the one nobody could read, and it was the best post on the site.
This is the signature of the most dangerous class of bug: it scales with your content, not with your code. Nothing was wrong in the file you'd open to debug it. The component was "correct." The data simply grew past a constant nobody thought of as a limit.
Who Actually Loses the Content
It's tempting to file this under "animation glitch" and move on. It isn't. opacity: 0 content is a presentation lie told to everyone, and the list of who's harmed is longer than you'd guess.
Readers, when the observer never fires. The headline case. A human opened a published article and got a blank page. No error, no spinner, no fallback — just absence.
Anyone without JavaScript. The reveal logic only runs after hydration. Turn JS off, or hit the page before the bundle loads on a slow connection, or watch the script 404 behind a flaky CDN, and the initial state — hidden — is the only state. The content never un-hides because the code that un-hides it never runs.
Crawlers and scrapers that don't render. This is the part people get wrong, so let me be precise. Google's crawler does execute JavaScript and does render the DOM, so it can often still read text that's technically at opacity: 0. But "often" is not "guaranteed," and Google is not the only reader that matters. Plenty of crawlers, link unfurlers, RSS fetchers, and the LLM-training and answer-engine scrapers that increasingly drive discovery do not run a full render pass. They read the HTML you served. If your content's visibility depends on a client-side side effect, those readers see whatever your server painted — and here, the server painted it hidden. On top of that, search engines actively discount text that's visually hidden from users; "in the DOM but invisible" is the exact shape they're trained to distrust.
The error case. Any exception thrown between mount and io.observe() — a bad ref, a polyfill that misbehaves, an observer that never gets a callback because the element was display-none at the moment of observation — strands the content hidden with no recovery path.
The common thread: the content's existence was made conditional on the happy path of an animation. That's the actual defect. The threshold was just the trigger.
The Fix Is an Inversion, Not a Tweak
The cheap fix is to change 0.15 to 0. It even works for this specific page. But it leaves the architecture exactly as fragile — content still defaults to hidden, still depends on JS firing correctly, still vanishes for every no-JS and non-rendering client. You'd have fixed the symptom and kept the disease.
The right fix inverts the default. Content is visible. The animation is an enhancement layered on top of already-visible content — and an enhancement, by definition, is allowed to fail without taking the content with it. This is just progressive enhancement applied to motion, and it changes three things:
1. Render visible by default. The server-rendered HTML, the no-JS experience, the pre-hydration paint, the dead-observer case — all of them now show the content. There is no code path where the content's existence depends on JavaScript succeeding.
2. Only hide what's below the fold. If you default to visible and then hide-to-animate, above-the-fold content flickers: visible on first paint, then yanked to transparent, then faded back in. So check position first. Anything already on screen stays put and never animates — which is correct, because content already in view doesn't need an entrance. Only content below the fold — which the reader hasn't seen yet, so hiding it costs nothing — gets armed for the reveal.
3. Make the trigger height-independent. For the below-fold elements that do animate, observe at threshold: 0 so the reveal fires on the first visible pixel, gated by a rootMargin so it triggers a beat before the element's edge. No ratio ceiling. A wrapper ten screens tall reveals exactly as reliably as a button.
Here's the shape:
const [shown, setShown] = useState(false);
const [armed, setArmed] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setShown(true); // never hide, never animate
return;
}
const r = el.getBoundingClientRect();
const vh = window.innerHeight;
if (r.top < vh - 60 && r.bottom > 0) {
setShown(true); // already on screen → reveal now, no flash
return;
}
setArmed(true); // below the fold → arm hidden, then reveal
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShown(true);
io.disconnect();
}
},
{ threshold: 0, rootMargin: '0px 0px -60px 0px' },
);
io.observe(el);
return () => io.disconnect();
}, []);
// hidden = armed && !shown
// opacity: hidden ? 0 : 1Note the two state flags. shown alone can't express the difference between "not yet revealed, and it matters" and "not yet revealed, but it was never armed so it should just be visible." armed carries that. The element is hidden only when it was explicitly armed and hasn't revealed yet — armed && !shown. Every other combination, including the default initial render and the no-JS case, resolves to visible. The hidden state is now a small, deliberate island reachable only when JavaScript has confirmed the element is off screen and a real reader is on their way to it.
The reduced-motion branch falls out naturally: those users skip the hide entirely and read static content. And because the position check runs in useLayoutEffect — before the browser paints — the below-fold hide happens off screen, with no visible flicker for the reader at the top of the page.
What I Changed My Mind About
I used to think of scroll reveals as harmless polish. Decoration. A thing you sprinkle on and never think about again. This bug reframed it: the instant an animation controls whether content is visible, it stops being decoration and becomes infrastructure — and infrastructure has failure modes you're accountable for.
The test I now apply is one sentence: if every line of my animation code silently failed to run, would the content still be there? For the old component the answer was no — and "no" is the bug, independent of the threshold, independent of the viewport, independent of whether anyone ever happened to load a post long enough to expose it. The threshold just decided which day it would surface.
It's the same lesson I keep relearning in different costumes. A contrast regression ships because it's invisible to the person who shipped it. A reveal animation hides an article because it only breaks past a length nobody tested at. The defects that reach production are rarely loud. They're the ones that look correct in the file, pass every check you wrote, and wait for the one input — a brighter pixel, a longer post — that no one thought to try.
The fix for the visible bug took one inverted default. The fix for the class of bug is a habit: never let something that's allowed to fail decide whether your content exists.
Building something where the details have to be right under real-world inputs, not just the demo? Let's talk.