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);
}
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);
}
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});
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.
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);
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);
}
âŚ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 withindocument.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.
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);
}
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. đŚ