The browser tells you, for free, when any element enters or leaves the viewport — no scroll listeners, no layout thrash. Scroll down: the dashed boxes light up the moment they cross your visibility threshold, and every crossing is logged.
Then try the A/B test demo: it only enrolls you in the experiment after the test content scrolls into view — which is how you stop counting people who never saw your variant.
threshold = how much of a box must be on screen before it counts as "seen": 0% fires the moment a single pixel appears, 100% only when the whole box is visible. rootMargin grows or shrinks the watched area — try "-100px" to pull the edges inward.
The classic A/B testing mistake: your variant lives halfway down the page, but you enroll every page load into the test. Visitors who bounced before ever seeing the variant count as non-converters — diluting your numbers and hiding real winners. The fix is one observer: enroll a visitor only when the experiment zone actually enters their viewport.
VARIANT A
VARIANT B
↑ scroll this zone into view to get enrolled (threshold 0.5) ↑
| metric | value |
|---|---|
| assigned variant | — (not enrolled yet) |
| enrolled at | — |
| conversions | 0 |
// enroll a visitor only when the experiment zone is actually seen const io = new IntersectionObserver((entries) => { for (const e of entries) { if (e.isIntersecting) { enrollInExperiment(e.target.dataset.variant); io.unobserve(e.target); // count each visitor once } } }, { threshold: 0.5 }); io.observe(document.querySelector('#experiment-zone'));
The callback runs off the main thread's layout work — unlike a scroll handler, it never forces the browser to recalculate styles to answer "is this visible?". That's why it's the right tool for lazy-loading images, infinite scroll, impression tracking, scroll-spy navs — and gating experiment enrollment on actual visibility.