Why observers exist
A scroll handler fires dozens of times a second, and every call that reads getBoundingClientRect forces a layout. The observers do the same job off the main thread and call you only when something actually changed.
There are three you will use: one for "is it on screen", one for "has it changed size", one for "has the DOM changed".
IntersectionObserver
- The callback fires once immediately for each element you observe, with its current state. That is a feature — you do not have to check separately.
rootMarginin CSS units grows or shrinks the box:"200px"triggers 200px early, which is how you lazy-load before the reader arrives.thresholdis how much of the element must be visible. An array gives you a callback at each crossing.
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
entry.target.classList.add("in");
io.unobserve(entry.target); // once is enough
}
}, {
root: null, // the viewport; or a scrolling element
rootMargin: "-10% 0px", // shrink or grow the trigger area
threshold: 0, // 0 = any pixel, 1 = fully visible, [0, .5, 1] = several
});
document.querySelectorAll("[data-reveal]").forEach((el) => io.observe(el));
io.disconnect(); // stop everything
// lazy loading (though loading="lazy" is better)
// infinite scroll: observe a sentinel at the end of the list
// "which section am I in": observe every section with a threshold
ResizeObserver
A resize event on window only tells you the window changed. A ResizeObserver fires when the element changes — including because a sibling grew, a font loaded, or content was added. It is what you need for a component that has to know its own width.
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
entry.target.classList.toggle("is-narrow", width < 400);
}
});
ro.observe(card);
ro.disconnect();
That schedules another callback, and the browser logs "ResizeObserver loop completed with undelivered notifications". Write to a different element, or guard against re-entry.
MutationObserver
Use it when you do not control the code that changes the DOM — a third-party widget, a CMS, content pasted into an editor. If you do control it, do the work at the point of the change instead; an observer there is indirection with no benefit.
const mo = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
record.addedNodes.forEach(enhance);
}
if (record.type === "attributes") {
console.log(record.attributeName, record.oldValue);
}
}
});
mo.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-state"],
attributeOldValue: true,
characterData: false,
});
mo.disconnect();
All three share an API
observe(target, options)— start watching.unobserve(target)— stop watching one.disconnect()— stop watching everything.- The callback gets an array of entries, because several can change in one frame.
An observer holds a reference to every element it watches. Never disconnecting is the same leak as never removing a listener.
A reveal that respects the reader
The fallback matters: if the observer never runs — reduced motion, an old browser, a bug — the content must still be visible. An entrance animation that fails closed hides the page.
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
export function reveal(root = document) {
const targets = root.querySelectorAll("[data-reveal]:not(.in)");
if (reduced || !("IntersectionObserver" in window)) {
targets.forEach((el) => el.classList.add("in")); // just show them
return;
}
const io = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
e.target.classList.add("in");
io.unobserve(e.target);
}
}, { rootMargin: "-8% 0px -8% 0px" });
targets.forEach((el) => io.observe(el));
}