Observing rendered DOM nodes
Tags: javascript
Published: 2021-01-20 05:11:10

---
title: Observing rendered DOM nodes
published: true
date: 2021-01-20 00:00:00 UTC
tags: javascript
canonical_url: https://whistlr.info/2021/observing-dom/
cover_image: https://storage.googleapis.com/hwhistlr.appspot.com/assets/node-io-hack.webp
---

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](https://whistlr.info/2020/understanding-load/#an-alternative)—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](https://en.wikipedia.org/wiki/Does_exactly_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](https://www.w3.org/TR/resize-observer/#intro)). 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:

```js
/**
 * @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);
}
```

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:

```js
/**
 * @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);
}
```

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](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver). It _replaces_ something called [Mutation events](https://developer.mozilla.org/en-US/docs/Web/Guide/Events/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:

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

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.

<img alt="Animation showing a parent node being removed" src="https://storage.googleapis.com/hwhistlr.appspot.com/assets/node-mutationobserver.webp" width="1200" height=600" />

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:

```js
/**
 * @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);
```

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:

```js
/**
 * @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);
}
```

&hellip;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](https://developers.google.com/web/updates/2019/02/intersectionobserver-v2) 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.

<img alt="Animation showing creating a bounding box around a specific element before that element moves out of that bounding box" src="https://storage.googleapis.com/hwhistlr.appspot.com/assets/node-io-hack.webp" width="1200" height=600" />

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):

```js
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);
}
```

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](https://codepen.io/samthor/pen/da8b24343b9dcd059cb359392c0df866). I've also pulled out an improved `vizObserver` function as a small library [you can find on GitHub](https://gist.github.com/samthor/37a8555218e8a69ebf9f1eaf20f751db). 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](https://caniuse.com/resizeobserver). 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](https://twitter.com/samthor) what you think. 🐦