Check if an Element Is in the Viewport
Lazy-loading images, animating sections on enter, and infinite scroll all start with the same question: is this element on screen? The classic answer was a scroll handler plus `getBoundingClientRect`, but `IntersectionObserver` is now the right tool: passive, batched, and rate-limited by the browser. This snippet covers the synchronous bounds check, the async observer-based watcher, and a one-shot helper that resolves once a target enters the viewport.
512 views
11
function isInViewport(el) {
const rect = el.getBoundingClientRect();
const vh = (typeof window !== 'undefined' && window.innerHeight) || 0;
const vw = (typeof window !== 'undefined' && window.innerWidth) || 0;
return rect.top < vh && rect.bottom > 0 && rect.left < vw && rect.right > 0;
}
const node = document.createElement('div');
console.log('in viewport (stub):', isInViewport(node));Comparing getBoundingClientRect() against innerHeight and innerWidth is the simplest check: any overlap between the element's rect and the viewport rectangle counts as visible. The four conditions cover top-clipped, bottom-clipped, left-clipped, and right-clipped cases without missing partial visibility. The catch is that getBoundingClientRect triggers layout, so calling it inside a scroll handler on every event will jank the page. Use this only for one-off checks; reach for the observer when the answer needs to update over time.
function watchVisibility(el, callback, options = { threshold: 0 }) {
if (typeof IntersectionObserver === 'undefined') {
const stop = () => {};
return stop;
}
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) callback(entry.isIntersecting, entry);
}, options);
observer.observe(el);
return () => observer.disconnect();
}
const banner = document.createElement('div');
const stop = watchVisibility(banner, (visible) => {
console.log(visible ? 'show animation' : 'pause animation');
});
stop();
console.log('observer wired');IntersectionObserver reports visibility changes asynchronously and lets the browser batch its work, so even hundreds of observed elements stay cheap. The threshold option controls the visibility ratio that fires the callback (0 means any pixel, 1 means fully on screen, an array fires at every step). Returning a disconnect function makes it trivial to wire into React useEffect, Vue's onUnmounted, or any cleanup hook. For a feature-detection fallback, expose a no-op cleanup so callers do not have to special-case unsupported environments.
function whenVisible(el, options = { threshold: 0.1 }) {
return new Promise((resolve) => {
if (typeof IntersectionObserver === 'undefined') return resolve();
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
observer.disconnect();
resolve(entry);
return;
}
}
}, options);
observer.observe(el);
});
}
const lazy = document.createElement('img');
// In a real page: await whenVisible(lazy); lazy.src = realUrl;
whenVisible(lazy).then(() => console.log('image entered viewport'));
console.log('one-shot wired');A promise-based wrapper is the right shape when the caller only cares about the first time an element enters the viewport, e.g. lazy-loading a high-resolution image, kicking off a fetch for above-the-fold content, or firing an analytics impression event. Disconnecting the observer inside the callback ensures the promise resolves exactly once, avoiding leaks and double-fires. The fallback resolve() for environments without IntersectionObserver keeps SSR and JSDOM happy. Tune threshold to control how much of the element must be visible before the resolve fires.
