Observers

React to the page changing without a scroll or resize handler.

26of 32
≈ 10Min read
6Parts

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.
  • rootMargin in CSS units grows or shrinks the box: "200px" triggers 200px early, which is how you lazy-load before the reader arrives.
  • threshold is how much of the element must be visible. An array gives you a callback at each crossing.
the reveal-on-scroll pattern
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
three things it replaces
// 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.

when an element changes size for any reason
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();
Do not resize the observed element inside the callback

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.

when the DOM itself changes
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.
Disconnect when the component goes

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.

the complete version
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));
}

Practice