D
P
0

JavaScript

`IntersectionObserver` Never Fires on a 350vh Sticky Section? The Threshold Is Mathematically Impossible

July 20, 2026·5 min read
`IntersectionObserver` Never Fires on a 350vh Sticky Section? The Threshold Is Mathematically Impossible

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 IntersectionObserver callback runs but isIntersecting never goes true, check the maximum intersectionRatio you 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 threshold larger than that maximum ratio. When in doubt, use threshold: 0 and control timing with rootMargin.
  • 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.