DEV Community

Cover image for Observing rendered DOM nodes
Sam Thorogood
Sam Thorogood

Posted on • Originally published at whistlr.info on <time datetime="2021-01-20T00:00:00Z" class="date-no-year">Jan 20</time>

Observing rendered DOM nodes

When building webpages, we create and use HTML elements, but this is often a decidedly one-way interface. And while you can continually request information about how a node is being rendered through methods like Element.getBoundingClientRect() or window.getComputedStyle(), it's difficult to be notified when an element's render changes.

This post will explain how to be notified when:

  • an element is added or removed from the DOM
  • the bounding box of an element changes (i.e., resizes)
  • an element moves around the page for any reason

I personally enjoy having these approaches in my toolbox 🛠️ and I hope you find them useful too!

1. DOM addition and removal notifications

You might, in your toolbox, want to be informed when a particular DOM node is added or removed from the page. As a digression: for Web Components, this is really easy—Web Components provide connectedCallback and disconnectedCallback methods, which are literally called when the WC is added and removed.

Instead, this section is going to talk about doing this for arbitrary 'classic' DOM nodes, such as your friendly neighborhood <div> or <button>. There's actually no perfect solution, but read on 👇

Using ResizeObserver to track appearance

The ResizeObserver interface does what it says on the tin: it informs you whether a target element has changed in size. However, a little-known benefit of this is that it'll also tell you when that element is added or removed from the DOM (see the spec). This works because an element that's off the page has zero size—but this introduces an interesting caveat.

If your node has a style of say, display: none while it's on the page, it already has zero size: so as nothing actually changes when it's added and removed from the DOM. The ResizeObserver won't be triggered.

Instead, we can easily track the appearance of a single DOM node. This looks like:

/**
 * @param {Element} element to track appearance for
 * @param {(appearing: boolean) => void} callback when appearance changes
 */
function observeElementAppearing(element, callback) {
  const ro = new ResizeObserver(() => {
    const r = element.getBoundingClientRect();
    const appearing = Boolean(r.top && r.left && r.width && r.height);
    callback(appearing);
  });
  ro.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

This is very simple and doesn't require knowledge of the target element's context, such as its peers or parent. For some of my projects, I'm actually happy with the caveat: an element I'm interested in is gone because it's either off the page or has zero size. I don't care which one, I'll set up or tear down some code based on that.

Using IntersectionObserver

If you're worried about browser support, It's worth noting that ResizeObserver was only added to Safari in March 2020, in a 13.x release. Another helper, IntersectionObserver, was introduced a year earlier in 12.x, and has slightly wider support among other browsers, too. It's ostensibly for tracking the visibility of elements as they appear in your scroll viewport (to lazy-load images and so on), but it can also be used with arbitrary parent elements.

In this case, we can actually ask the browser whether an element has any of its size within document.body, and be informed when that changes:

/**
 * @param {Element} element to track appearance for
 * @param {(appearing: boolean) => void} callback when appearance changes
 */
function observeElementAppearing(element, callback) {
  const io = new IntersectionObserver(() => {
    const r = element.getBoundingClientRect();
    const appearing = Boolean(r.top && r.left && r.width && r.height);
    callback(appearing);
  }, {root: document.documentElement, threshold: 0.0});
  io.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

This code looks almost the same as above, and works the same way—we're not strictly told about removals, but rather, the apperance.

Using MutationObserver

There's also a helper called MutationObserver to help us track changes to DOM nodes. You can read about it on MDN. It replaces something called Mutation events, a long-since deprecated API with low browser support.

The main downside for MutationObserver is that you get a firehouse of all events, as the only way to reliably be informed about page-level changes is to observe the entire <body> element. (And if you're interested in Shadow DOM changes, you'll also have to observe individual #shadow-root nodes.) 🕴️

You can set up a global MutationObserver like this:

// We don't implement the callback yet.
const mo = new MutationObserver((entries) => { /* ... */ });
mo.observe(document.body, {subtree: true, childList: true});
Enter fullscreen mode Exit fullscreen mode

The callback you receive will tell you about any nodes added and removed from document.body. However, it's important to note that only the "parent" of any addition or removal will trigger the callback.

Animation showing a parent node being removed

What this means in practice is that you'll have to check the descendants of any node being changed in case you're interested in their status. If you want to get all individual added and removed nodes, you could traverse them:

/**
 * @param {NodeList} nodes
 * @param {Node[]} out
 * @return {Node[]}
 */
const traverseAllNodes = (nodes, out = []) => {
  out.push(...nodes);
  nodes.forEach((node) => traverseAllNodes(node.children, out));
  return out;
};

const mo = new MutationObserver((entries) => {
  for (const entry of entries) {
    const allAddedNodes = traverseAllNodes(entry.addedNodes);
    const allRemovedNodes = traverseAllNodes(entry.removedNodes);
    // do something with added/removed nodes
  }
});
mo.observe(document.body);
Enter fullscreen mode Exit fullscreen mode

This is correct, but could be slow. If you're only interested in a small number of nodes changing, you could instead ignore entries completely and just check whether a target node .isConnected whenever the callback is fired.

⚠️ To be very clear, you cannot directly observe single nodes status in the DOM with MutationObserver, or even via the childList of a target node's parent. As the animation above shows, an element might disappear from the page because of something that happened to any of its ancestors.

2. Bounding box changes

This is really the smallest section of this post, and in many ways, is a superset of my suggested approach above. You can literally just use ResizeObserver, as—turns out—informing you of an element's resize is its primary goal. This looks like:

/**
 * @param {Element} element to track size
 * @param {(bounds: DOMRect) => void} callback when size changes
 */
function observeSize(element, callback) {
  const ro = new ResizeObserver(() => {
    const r = element.getBoundingClientRect();
    callback(r);
  });
  ro.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

…you could also dispense with the helper method and just use ResizeObserver directly.

Something I've often found useful is that it's valid to observe <body> (or <html>, which works the same way). This can tell you whether the whole whole page has changed size. 📄

Unfortunately, ResizeObserver won't tell you if an element moves—you can reposition the same DOM node around the page, and if its bounds don't change, this callback won't fire. (Read on!)

3. Move observations

As well as resize, you might want to know if an element moves on the page. This can be niché: it's your webpage, so you likely have a good idea if change you make (like a CSS class or maniplating DOM) will cause a move.

For me, like the above notifications, this approach is useful to have around in my toolbox when I'm building something tricky. The example I'll use below is that of a tooltip which exists unrelated in the DOM via position: absolute—I need to keep it positioned adjacent to my button of choice while not sharing any common parts of the element heirarchy.

Using IntersectionObserver

It's possible to overload the IntersectionObserver helper to detect moves. I introduced this above, but it's worth restating: if you were to read about this API, you'd believe it's for tracking element visibility—for lazy-loading, or seeing whether users can see your ads and so on. And the most common use case is to determine what proportion of an element is currently visible on the page, expressed as a ratio of its total size.

But it has a couple of interesting options we can play with:

  • we can choose a parent element to observe within—by default, IntersectionObserver uses the scroll viewport, not a specific element (we used this above to observe within document.documentElement)
  • we can set a rootMargin to expand or constrain the physical space being observed
  • we can set a threshold for callback

By observing document.body and getting creative with its rootMargin, we can construct a bounding box which fits around any specific element. If it moves, and our threshold is set to 1.0, we'll be notified—the element starts intersecting 100% with the target range, but as soon as it moves away from the bounding box we'll be triggered—as its visible ratio will go lower than 1.0.

Animation showing creating a bounding box around a specific element before that element moves out of that bounding box

There's a couple of nuances here. We also have to keep track of the size of the <body> element, because the right and bottom margins in rootMargin cannot use calc() (i.e., we cannot use say, the total width or height minus the offset)—so if it resizes, we have to recreate the IntersectionObserver.

So, with that in mind, the code roughly ends up like this (this has some issues, don't just copy and paste it):

const root = document.documentElement;

// Observe the whole document
const vizObservers = new Set();
const documentResizeObserver = new ResizeObserver(() => {
  vizObservers.forEach((fn) => fn());
});
documentResizeObserver.observe(root);

/**
 * @param {Element} element to observe
 * @param {(rect: DOMRect) => void} callback on move or resize
 */
function vizObserver(element, callback) {
  let io = null;

  const refresh = () => {
    io?.disconnect();

    // Inform the user that the bounding rect has changed.
    // If it's zero, we can't build an IntersectionObserver.
    const rect = element.getBoundingClientRect();
    callback(rect);
    if (!rect.width || !rect.height) { return; }

    // Construct the margin in the form "top right bottom left".
    // This needs to be -ve and always rounded _down_.
    const invertToPx = (value) => `${-Math.round(value)}px`;
    const rootMargin = [
      rect.top,
      root.offsetWidth - (rect.left + rect.width),
      root.offsetHeight - (rect.top + rect.height),
      rect.left,
    ].map(invertToPx).join(' ');

    // Watch for intersection change. Ignore the first update
    // as it should always be 1.0.
    let isFirstUpdate = true;
    io = new IntersectionObserver((entries) => {
      if (isFirstUpdate) {
        isFirstUpdate = false;
      } else {
        refresh();
      }
    }, {root, rootMargin, threshold: 1.0});
  };
  vizObservers.add(refresh);

  // Observe size, since size changes refresh.
  const ro = new ResizeObserver(() => refresh());
  ro.observe(element);
}
Enter fullscreen mode Exit fullscreen mode

This is a pretty long snippet, but I've tried to add some comments. The core of this is building rootMargin: we need to find the insets from the sides of the root element, make them negative, and ensure they're rounded down—IntersectionObserver works on pixel boundaries, but DOM nodes can technically have floating-point size. 📏

⚠️ Due to this rounding, it's also possible that we get an initial callback of intersectionRatio slightly less than one—e.g., 0.9991451 or a very high floating-point value. The snippet above doesn't deal with this, but you actually need to recreate the IntersectionObserver at this point too. Due to the way it works, we're only told once we transition past any specific threshold—and in this case, we've already transitioned past the 1.0 threshold—we won't be called back again—so we need to create it again.

If you'd like to play more with this, I've built a demo ➡️ over on Codepen. I've also pulled out an improved vizObserver function as a small library you can find on GitHub. It's also worth noting that the way we track moves, by necessity, also ends up informing you about element resize and appearance (#1 and #2).

Summary

These raw primitives IntersectionObserver and ResizeObserver are very powerful and help us keep track of new and interesting things in ways that weren't possible before. They're largely supported by evergreens, although as of writing, ResizeObserver has slightly less support—it wasn't available until a 13.x release of Safari. That's about 15% of Safari users you can't support, although personally, I'll be embracing ResizeObserver in my web projects in 2021 anyway.

For me, I'll be using these primitives in a few ways, but I hope you find them useful in others, too. My use-case is mostly my last example: I want to align tooltips to arbitrary elements—which I don't want to directly pierce into, as I've written a good abstraction—even though they have no DOM in common. By keeping track of an element's position and size, I can ensure that the tooltip correctly "follows" the target.

Thanks for reading! Let me know on Twitter what you think. 🐦

Discussion (0)