The Intersection Observer API provides an asynchronous, non-blocking way to detect when an element enters or exits the browser viewport — replacing the scroll event listener pattern that required polling getBoundingClientRect() on every scroll frame, which causes main-thread jank and degrades INP. An IntersectionObserver instance takes a callback and an options object that specifies the root element (defaults to viewport), rootMargin (expands or contracts the root for early or late triggering), and threshold (a value or array of values between 0 and 1 indicating the percentage of the target visible before the callback fires). The observer callback receives an array of IntersectionObserverEntry objects — each entry has isIntersecting (true when the element enters the root), intersectionRatio (the fraction currently visible), target (the observed element), and boundingClientRect without triggering a layout reflow. For lazy loading images in WordPress, the native loading="lazy" attribute (supported in all evergreen browsers and automatically added by WordPress 5.5+) handles the common case — Intersection Observer is the correct tool for custom lazy loading patterns that need to control the load trigger distance, swap image sources based on connection speed, or trigger fetch-on-demand of heavy iframes and video embeds. Scroll-triggered animations use the observer to toggle a CSS class when an element enters the viewport — the animation is defined entirely in CSS using @keyframes, keeping the JavaScript small and the animation performant via GPU-composited properties (transform and opacity only). The unobserve() method stops watching a specific element after it has triggered — essential for one-shot animations where re-triggering on scroll-up is undesirable. The Service Worker post covered background network patterns; Intersection Observer covers the front-end rendering performance layer.
Problem: A WordPress theme uses a jQuery scroll event listener to animate 40 elements as the user scrolls — on a mid-range Android device the scroll handler fires 60+ times per second, calls getBoundingClientRect() on each element, triggers forced reflows, and causes visible frame drops that fail Google’s INP threshold.
Solution: Replace the scroll listener with an IntersectionObserver that watches all animation targets, fires once when each enters the viewport to add an is-visible CSS class, and unobserves the element to prevent repeated triggers — eliminating all JavaScript scroll work after initial setup.
// scroll-animations.js — enqueued with 'defer' strategy
(function () {
'use strict';
// ── 1. Scroll-triggered CSS class animations ─────────────────────────────
const animationObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
animationObserver.unobserve(entry.target); // fire once only
});
},
{
rootMargin: '0px 0px -80px 0px', // trigger 80px before bottom of viewport
threshold: 0.15, // fire when 15% of element is visible
}
);
document.querySelectorAll('[data-animate]').forEach((el) => {
animationObserver.observe(el);
});
// ── 2. Lazy-load heavy iframes (YouTube, maps) on demand ──────────────────
const iframeObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const placeholder = entry.target;
const src = placeholder.dataset.src;
if (!src) return;
const iframe = document.createElement('iframe');
iframe.src = src; // safe: set from data-src attribute
iframe.width = placeholder.dataset.width || '560';
iframe.height = placeholder.dataset.height || '315';
iframe.loading = 'lazy';
iframe.allow = 'accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture';
iframe.allowFullscreen = true;
iframe.title = placeholder.dataset.title || 'Embedded content';
placeholder.replaceWith(iframe);
iframeObserver.unobserve(placeholder);
});
},
{ rootMargin: '200px' } // start loading 200px before entering viewport
);
document.querySelectorAll('[data-lazy-iframe]').forEach((el) => {
iframeObserver.observe(el);
});
// ── 3. Progress bar fill as section scrolls through viewport ──────────────
const progressObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const bar = entry.target.querySelector('.progress-bar');
if (!bar) return;
// intersectionRatio: 0 → 1 as element scrolls into full view
bar.style.width = Math.round(entry.intersectionRatio * 100) + '%';
});
},
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100), // 0, 0.01, 0.02 ... 1
}
);
document.querySelectorAll('.progress-section').forEach((el) => {
progressObserver.observe(el);
});
})();
/* Animate only GPU-composited properties (transform, opacity) for 60fps */
[data-animate] {
opacity: 0;
transform: translateY(24px);
transition: opacity 0.5s ease, transform 0.5s ease;
will-change: opacity, transform; /* hint browser to promote to composite layer */
}
[data-animate].is-visible {
opacity: 1;
transform: translateY(0);
}
/* Respect prefers-reduced-motion for accessibility */
@media (prefers-reduced-motion: reduce) {
[data-animate] {
opacity: 1;
transform: none;
transition: none;
}
}
NOTE: Always add @media (prefers-reduced-motion: reduce) overrides that skip or instant-show animations for users who have set this system preference — this affects a significant portion of users with vestibular disorders or motion sensitivity. The WCAG 2.1 Success Criterion 2.3.3 (Level AAA) and the upcoming WCAG 2.2 require animations to be pauseable or disableable. Setting transition: none and revealing elements immediately when prefers-reduced-motion is active satisfies this requirement while keeping the JavaScript logic unchanged.