A client site had one tall "mission" section: 350vh, with a panel that sticks to the center of the screen while you scroll past it. Inside that panel three elements were supposed to animate in the moment the section entered the viewport: a small eyebrow on top, a big heading, and a body paragraph. It looked fine in a quick local pass, but during review the client reported that the text in the mission panel did not show up at all.
Only the background showed. The eyebrow stayed clipped, invisible. The heading sat frozen at translateY(110%), still below its line and masked. The body paragraph stayed blurred at opacity: 0. All three were waiting on the same thing: an .is-revealed class that was supposed to be added to the section once it became visible. That class never arrived.
The symptom
DevTools showed the section was there, the elements were there, the CSS was correct. If I added .is-revealed by hand through the inspector, all three elements revealed smoothly. So the CSS was healthy. The problem was the JavaScript that was supposed to add that class.
The code used IntersectionObserver. I assumed it was trivial. I dropped a console.log inside the callback:
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log('ratio', entry.intersectionRatio, entry.isIntersecting);
if (entry.isIntersecting) root.classList.add('is-revealed');
});
}, { threshold: 0.3 });
obs.observe(section);I scrolled slowly from top to bottom through the section. The callback ran, the logs printed, but isIntersecting never went true. The ratio climbed, topped out around 0.28, then dropped again. It never touched 0.3. The callback fired, but its if branch never did.
The dead ends I tried first
My first instincts were all wrong. I assumed a timing bug: maybe the section was not laid out yet when the observer was created. I wrapped it in requestAnimationFrame, no effect. I suspected smooth scroll (Lenis) was keeping the observer from updating, so I disabled it temporarily, still no fire. I thought the observer root was wrong, so I set it explicitly to null, the viewport, same result.
Every one of those guesses missed because I was not reading the number already sitting in front of me: the ratio capped at 0.28.
The root cause: the threshold was impossible to satisfy
This was not a timing bug or a scroll-library bug. It was arithmetic.
intersectionRatio is the visible area of the element divided by its total area. My section was 350vh tall. The viewport was 100vh. No matter how you scroll, the most of a 350vh element that can ever be visible inside a 100vh window is 100 out of 350, which is 0.286.
A threshold of 0.3 asks for 30% of the element to be visible at once. For an element 3.5 times taller than the viewport, that is impossible. The ratio physically cannot reach 0.3. The observer was healthy, the callback ran, but the condition I gave it could never be true. isIntersecting in this API is computed against the active threshold, so it too never went true at the point I expected.
Dropping the threshold to 0.2 was not the answer either. 0.2 would fire, but it fires when the section is only a fifth on screen, not when the sticky panel is actually seated in the center and the text is ready to read. The reveal would trigger too early and out of sync with the moment I wanted.
The fix: observe an element with a sane size
The key: do not observe the 350vh container. Observe the 100vh sticky panel inside it (.section__sticky-inner). That element is proportional to the viewport, so its ratio can climb all the way to full and the threshold becomes meaningful again.
Since all I needed was to know whether the panel had entered the screen yet, I used threshold: 0 plus a rootMargin so the reveal fires a little after the panel is genuinely in, not the instant its edge grazes the viewport:
const target = root.querySelector('.section__sticky-inner') || root;
const obs = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
root.classList.add('is-revealed');
obs.disconnect();
}
});
}, { threshold: 0, rootMargin: '0px 0px -5% 0px' });
obs.observe(target);One more thing I added after getting bitten once: a safety timer. IntersectionObserver can stay silent in a few edge cases, for example a page restored from bfcache, a smooth-scroll library that has taken over scrolling, or a user on reduced-motion. So I added a 4-second fallback that forces the reveal if the element is on screen but the observer has not spoken yet:
setTimeout(() => {
if (!root.classList.contains('is-revealed')) {
const r = root.getBoundingClientRect();
if (r.top < window.innerHeight && r.bottom > 0) {
root.classList.add('is-revealed');
}
}
}, 4000);After those two changes the text reveals every time, under normal scroll and smooth scroll alike, and the "text does not show up" reports stopped.
Takeaways
- If an
IntersectionObservercallback runs butisIntersectingnever goestrue, check the maximumintersectionRatioyou actually reach first. - For an element taller than the viewport, the maximum ratio equals viewport height divided by element height. A 350vh element in a 100vh viewport tops out at 0.286.
- Never set a
thresholdlarger than that maximum ratio. When in doubt, usethreshold: 0and control timing withrootMargin. - Observe an element proportional to the viewport, the 100vh sticky panel, not the giant scroll container around it.
- Add a fallback timer for edge cases like bfcache, smooth scroll, and reduced-motion, so the reveal can never get stuck permanently.
