The classic way to know when something scrolls into view is a scroll event listener that calls getBoundingClientRect() on every element you care about, on every single scroll frame — forcing a synchronous layout recalculation each time, dozens of times a second, for every element being watched. It works, but it's the kind of code that quietly tanks scroll performance on a long page and gets worse the more things you watch. The IntersectionObserver API replaces all of that: you describe what "in view" means once, hand it a set of elements, and the browser tells you — asynchronously, without your code ever forcing a synchronous layout recalculation to find out — exactly when each one crosses that line. No library, no polling, and it's been supported in every major browser for years.
The eight demos below are all real, interactive and scrollable — scroll inside each one and watch it react live. Every embed also has a Fork & Edit button that opens the exact snippet, already running, in My Code — FWD Tools' free in-browser editor. Change a threshold, a rootMargin, or a class name and watch what actually happens — that's a faster way to build real intuition for this API than any explanation.
If vanilla JS and browser APIs in general are still shaky ground, the JavaScript Playground is worth having open alongside this post — a free, click-based, in-browser course that runs beginner to pro, with dedicated chapters covering the DOM and browser APIs as a guided curriculum.
Why IntersectionObserver beats a scroll listener
A scroll handler runs constantly, whether or not anything interesting actually happened, and every getBoundingClientRect() call inside it forces the browser to recompute layout synchronously — the exact kind of work that causes visible jank on a busy page. IntersectionObserver flips the model entirely: you register a callback once, and the browser calls it only when an element's visibility actually crosses a threshold you defined, computed asynchronously and batched efficiently regardless of how many elements you're watching. Ten watched elements cost about the same as a thousand. That single architectural difference is why it's the right default answer for scroll-triggered animation, lazy loading, infinite scroll, and visibility tracking of every kind — the eight demos below are eight different shapes the same one API takes.
1. Fade-in on scroll
Each card fades and slides up the moment it enters view, once, and never re-triggers on a second pass.
The rule:
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
entry.target.classList.add('in');
io.unobserve(entry.target); // animate once, then stop watching it
});
}, { threshold: 0.2 });
document.querySelectorAll('.reveal').forEach(function (el) { io.observe(el); });
The callback receives an array of entries, not just one — if several observed elements cross the threshold in the same frame, they all arrive together in a single call, which is part of why this scales so well. Calling unobserve() the moment an element has done its job (rather than leaving it watched forever) is what keeps a page with hundreds of reveal animations from accumulating hundreds of live observers it no longer needs.
Best for: scroll-triggered entrance animations on landing pages, portfolios, and long-form content. Tip: a threshold of 0.2 here means "20% of the element must be visible" — raise it toward 1 for an animation that should only fire once something is almost fully on screen.
2. Lazy-loading images
Every image starts with an empty src and a placeholder pattern. Scroll, and each one's real data-src only gets assigned once it's about to enter view.
The rule:
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var img = entry.target;
io.unobserve(img);
img.src = img.dataset.src; // the real network request starts here
});
}, { rootMargin: '200px 0px' }); // start loading before it's actually visible
document.querySelectorAll('.lazy').forEach(function (img) { io.observe(img); });
rootMargin grows or shrinks the trigger area independently of the element's actual visible box — a positive value here means the image starts loading 200px before it would otherwise be considered "in view," so by the time a visitor actually scrolls to it, it's often already there. This is the same underlying technique the browser's own native loading="lazy" attribute uses; reach for IntersectionObserver directly when you need more control than that attribute gives you — a loaded-count readout like this demo's, a custom placeholder swap, or coordinating the load with something else on the page.
Best for: image-heavy pages, galleries, and feeds where loading every image up front would waste bandwidth on images the visitor may never scroll to. Tip: for plain <img> lazy-loading with no extra logic attached, loading="lazy" as a native HTML attribute needs zero JavaScript at all — this pattern earns its keep once you need custom behavior on top.
3. Infinite scroll list
A one-pixel-tall sentinel below the last row is what triggers loading the next batch — no scroll or resize listener anywhere.
The rule:
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
loadNextPage(); // a real fetch() call, in practice
}
}, { rootMargin: '150px' }); // fetch before the sentinel is fully on-screen
io.observe(sentinelElement);
The sentinel itself renders nothing — it's an empty element that exists purely as a trigger. Watching it instead of watching every row individually means the observer count stays at exactly one no matter how many rows have loaded, which is what keeps this pattern cheap at any list length. This is the same core technique behind the auto-loading list in FWD Tools' own UI Snippets sidebar — there, the observer watches the "Load next" button itself rather than a separate invisible element, so the button doubles as its own trigger and stays in the DOM as a manual fallback if the observer never fires.
Best for: feeds, search results, and any list too long to load in one request. Tip: keep the manual "Load more" button in the DOM alongside the sentinel, hidden or not, as a fallback for the rare case a reader's browser or extension blocks the observer — it costs nothing and guarantees the list is never permanently stuck.
4. Scrollspy nav highlighting
Scroll and watch the nav bar above react — no click needed. Whichever section is most visible gets the highlight.
The rule:
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
ratios[entry.target.id] = entry.isIntersecting ? entry.intersectionRatio : 0;
});
var bestId = Object.keys(ratios).reduce(function (a, b) {
return ratios[a] > ratios[b] ? a : b;
});
highlightNavLink(bestId);
}, { threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1] });
sections.forEach(function (s) { io.observe(s); });
A single number — isIntersecting — isn't enough here, because two adjacent sections are routinely both partly on screen at once during a scroll. Passing an array of thresholds instead of one number makes the callback fire again at every 10% step of visibility change, giving a live intersectionRatio precise enough to compare sections against each other and pick a genuine winner, rather than just flipping a boolean.
Best for: "you are here" table-of-contents navs on docs sites and long-form articles. Tip: a dense threshold array like this one costs more callback invocations than a single 0.5 — fine for a handful of sections, but worth trimming for a page watching dozens of them.
5. Count-up stats on scroll
Each number counts up from zero the instant it comes into view, once, and never restarts if you scroll past it again.
The rule:
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
animateCountUp(entry.target);
io.unobserve(entry.target); // count once — never restart on re-entry
});
}, { threshold: 0.6 });
document.querySelectorAll('.num').forEach(function (el) { io.observe(el); });
The count-up animation itself is a separate concern, driven by requestAnimationFrame with an eased progress curve — IntersectionObserver's only job is deciding the one moment to kick it off. That separation is worth keeping in every demo on this page: the observer decides when, plain DOM/CSS/animation code decides what. A threshold of 0.6 here means a stat card has to be genuinely mostly on screen before its number starts moving, not just barely peeking into view.
Best for: landing-page stat sections, dashboards, and anywhere a number should feel like it's "counting up" the first time a visitor sees it. Tip: always unobserve() after the animation starts — without it, scrolling past the stat and back triggers the count-up again every single time, which reads as broken rather than intentional.
6. Floating button on scroll
A "Back to top" button stays hidden while the hero is on screen, then fades in the instant it scrolls away.
The rule:
var io = new IntersectionObserver(function (entries) {
// isIntersecting is false the instant the hero has fully scrolled away —
// that single boolean is the entire "should the button show" condition.
toggleBtn.classList.toggle('show', !entries[0].isIntersecting);
}, { threshold: 0 });
io.observe(heroElement);
This demo watches the hero, not the button — the button's visibility is just the inverse of the hero's. That's a genuinely useful reframing for a lot of "show this when scrolled past that" UI: rather than tracking a scroll position number and comparing it to some pixel threshold you have to update every time the layout changes, watch the actual element that marks the boundary and let the browser tell you when it's gone.
Best for: back-to-top buttons, sticky "buy now" bars, and any UI that should only appear once a visitor has scrolled past a specific landmark. Tip: threshold: 0 means "any pixel of the hero visible at all counts as intersecting" — the button won't show until the hero is completely, not just mostly, off screen.
7. Auto-pause off-screen
This visualizer is "playing." Scroll it out of view and it pauses itself; scroll back and it resumes — the same pattern a background video or audio player uses.
The rule:
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { video.play(); }
else { video.pause(); }
}, { threshold: 0.5 });
io.observe(video);
Autoplaying video is a common source of wasted CPU, battery, and bandwidth on a long page — a background hero video or an audio visualizer still running while scrolled three screens away serves nobody. The same technique works for pausing a purely visual animation loop off-screen too — toggle a CSS class instead of calling play()/pause(), and the rest of the logic is identical.
Best for: background/hero videos, audio visualizers, and any looping animation expensive enough that it shouldn't run while nobody can see it. Tip: a threshold around 0.5 avoids a distracting play/pause flicker right at the edge of the viewport that a threshold: 0 would cause.
8. threshold & rootMargin visualizer
Drag the slider, then scroll the box through the striped zone. The readout is a real observer reporting its live intersectionRatio on every threshold step it crosses.
The rule:
var io = new IntersectionObserver(callback, {
root: null, // null = the browser viewport (or the nearest scrollable ancestor)
rootMargin: '0px', // shrinks/grows the trigger box, CSS-margin syntax
threshold: [0, 0.25, 0.5, 0.75, 1] // fire again at each of these visibility steps
});
These three options are the entire API surface, and they're read once, at construction — there's no method to update them on a live observer, which is why the slider in this demo tears down and rebuilds the observer on every change rather than mutating one in place. root defaults to the browser viewport but can be any scrollable ancestor element, which is exactly what makes this work correctly inside a scrolling card, modal, or sidebar rather than only the whole page. A negative rootMargin shrinks the trigger zone inward — useful for "must be well inside the viewport, not just barely peeking in" logic — while a positive one, as demo #2 uses, grows it outward for early triggering.
Best for: debugging exactly why an observer is firing earlier or later than expected, and building intuition before tuning the real thing in demos #1–7. Tip: when in doubt, start with threshold: 0 (fires on any visibility at all) and a single number rather than an array — reach for an array only once you genuinely need multiple callback firings across a visibility range, as demo #4's scrollspy does.
Common pitfalls
- Forgetting to call
unobserve()after a one-time effect. A fade-in, a lazy image swap, or a count-up animation should only ever fire once per element — withoutunobserve(), scrolling an element out and back triggers it again, every time, which reads as a bug rather than a feature. - Trying to change
thresholdorrootMarginon a live observer. Both are read once at construction and cannot be updated afterward — changing either genuinely requires disconnecting the old observer and creating a new one, as demo #8 does on every slider move. - Reaching for
threshold: 1and expecting it to reliably fire. An element taller than the viewport (or itsroot) can never reach 100% visibility at once, so a threshold of exactly1may never trigger for it — a fractional threshold like0.9is almost always the safer choice for large elements. - Watching hundreds of individual elements instead of one sentinel. Demo #3's infinite scroll watches a single empty sentinel element, not every row — watching every row individually works but scales far worse for no real benefit once a sentinel does the same job for one observer's worth of cost.
- Assuming
isIntersectingalone is enough for "which is most visible." When two elements are both partially on screen, as in demo #4's scrollspy, only comparing theirintersectionRatiovalues against each other tells you which one is genuinely more visible right now.
Frequently asked questions
Does IntersectionObserver work inside a scrolling container, not just the page?
Yes — pass that container element as the root option instead of leaving it at the default null (which means the browser viewport). The element being observed still needs to be a descendant of that root for intersection to be measured correctly.
Can I observe the same element with more than one observer?
Yes, and it's a completely normal pattern — each observer runs independently with its own options and callback. Demo #6's floating button and a hypothetical analytics tracker could both watch the same hero element with two separate observers and neither would interfere with the other.
Is browser support good enough to use this without a fallback?
IntersectionObserver has shipped in every major browser (Chrome, Firefox, Safari, Edge) for several years now and needs no polyfill for the vast majority of real-world traffic. Check caniuse.com for current numbers if a project specifically needs to support very old browser versions.
What's the difference between isIntersecting and intersectionRatio?
isIntersecting is a plain boolean — is any part of the element visible at all, relative to the thresholds you set. intersectionRatio is the actual number behind that boolean, from 0 to 1, representing exactly what fraction of the element is currently visible. Most simple show/hide logic only needs the boolean, as in demos #1, #2, #5, #6, and #7; anything comparing degrees of visibility against each other, like demo #4's scrollspy, needs the real ratio.
Should I use this instead of the native loading="lazy" attribute for images?
Not if plain lazy-loaded images are all you need — loading="lazy" on an <img> tag does that natively with zero JavaScript. Reach for IntersectionObserver, as demo #2 does, once you need something the attribute alone can't give you: a custom placeholder swap, a loaded-count readout, coordinating the load with an animation, or lazy-loading something that isn't a plain image at all.
Every demo above ships its full HTML, CSS and JS in the HTML / CSS / JS tabs in its own top bar. The fastest way to actually internalize the differences is to click Fork & Edit on demo #1, change its threshold from 0.2 to 0.9, and watch exactly how much further you now have to scroll before the card animates in. Once you've got a version you like, save it to My Code and it's yours to keep, tweak further, or reuse in a real project.
Recommended next step: open the JavaScript Playground and work through its DOM and browser APIs chapters — free, click-based lessons covering exactly this kind of ground, built for the pattern this post covers. For more tutorials like this one, browse the JavaScript label on the blog.
