12
How to get element bounds without forcing a reflow
Getting the element bounds (size and position) seems like a trivial task. Just use getBoundingClientRect() in loop on bunch of elements and you're done. The truth is, that works pretty well, except the one thing - a performance. You're likely to force a browser reflow. And when you have a huge amount of elements, the performance hurt can be significant.
In this post, I'm gonna show you a little bit unusual approach to getting element bounds with the use of IntersectionObserver
The reflow is a process when the browser needs to re-calculate the position and dimensions of the elements on the page. The reflow always occurs when the page is loaded and the browser needs to traverse the DOM to get all elements. This is very expensive (in the meaning of performance) and can make longer rendering, junky scrolling or sluggish animations.
Forcing a browser reflow can be done just by changing the width of the element by as little as 1px. Yes, it's so small amount, but the browser needs to check the new position of the element and also how it affected other elements on the page. So it's better to use a transform
property for that. But this is out of scope of this article.
This is the very old method of getting the element position using offsetTop
or offsetLeft
. Unfortunately, there is one (serious) detail to keep in mind - it returns the position relative to the parent element and not the absolute position relative to the page. Even there is a solution using offset.js script, it still forces reflow.
This one is more precise and easier to use. It returns the element size and position relative to the viewport. You'll get left
, top
, right
, bottom
, x
, y
, width
, and height
values of selected element. It's relatively fast when you have a small number of elements. But it's getting to be slower and forcing a reflow when the number of elements starts to rise dramatically, or when calling multiple time.
This is the relatively unknown approach of getting the dimension and position of the element, because of the IntersectionObserver
is primarily used to calculate the visibility of the element in the viewport.
As it's mentioned in the MDN docs:
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
The magic keyword - asynchronously is why the performance will thank you. All the calculations are done "off the main thread" so the browser has much time to do the optimizations.
But how to get element bounds with this, and what to do if the element is not even visible in the viewport?
In fact, you don't need to care. IntersectionObserver
API has a boundingClientRect
property that calculates the element dimension independently on its visibility.
The boundingClientRect
is the IntersectionObserver
API interface that returns a read-only value of the rectangle describing the smallest rectangle that contains the entire target element. It's like the getBoundingClientRect()
but without forcing a reflow. You'll get left
, top
, right
, bottom
, x
, y
, width
, and height
.
This property is accessible inside the IntersectionObserver
constructor via entry.boundingClientRect
.
Finally, let's take a look at how to use this all to get the element dimensions without making the browser hate us.
The full script looks like this:
// new `IntersectionObserver` constructor
const observer = new IntersectionObserver((entries) => {
// Loop through all `entries` returned by the observer
for (const entry of entries) {
// The `entry.boundingClientRect` is where all the dimensions are stored
const bounds = entry.boundingClientRect;
// Log the `bounds` for every element
console.log(bounds);
// Then do whatever with `bounds`
}
// Disconnect the observer to stop from running in the background
observer.disconnect();
});
// Select all the `.element` elements
const elements = document.querySelectorAll(".element");
// Loop through all elements
for (const element of elements) {
// Run the `observe` function of the `IntersectionObserver` on the element
observer.observe(element);
}
The entry.boundingClientRect
is where the magic happens. This property stores all the element dimensions and positions.
Now let's take a closer look on each definition.
The first step is to create a new IntersectionObserver
constructor that takes a list of elements as an argument and applies its calculations. Note to mention - you can pass custom options to the observer, but we're going to keep defaults one, as we don't need to track visibility.
const observer = new IntersectionObserver((entries) => {
});
Inside this IntersectionObserver
, we need to loop through all entries
that will be passed later in the loop. This is the place where you get elements bounds for further use{.bg-green .bg-opacity-20}. We'll use bounds
constant to store the entry.boundingClientRect
values so when you need to get x
or height
value of the element, just use bounds.x
or bounds.height
.
for (const entry of entries) {
const bounds = entry.boundingClientRect;
// Use `bounds` like you need
// Example: `bounds.height` will return the element `height` value in px
}
When the observing is done, it's good to disconnect the observer as we don't need it anymore.
observer.disconnect();
Then we need to select all the elements on which we need to determine their bounds. They'll be stored in the .elements
constant.
const elements = document.querySelectorAll(".element");
And finally, loop through all of them and run the observer on them. This may look like a synchronous call, but in fact, the IntersectionObserver is not triggered immediately when the observer.observe(element);
is called. Instead, it waits and then takes a bunch of elements and runs the calculations asynchronously.
for (const element of document.querySelectorAll(".element")) {
observer.observe(element);
}
To get an idea of how fast and performant the IntersectionObserver
is, I've made a quick comparison with the old getBoundingClientRect()
method.
I've generated 5000 squared <div>
elements and give them a .element
class with basic stylings such as size and background color. There are no other elements that could affect performance.
Now let's compare the getBoundingClientRect()
vs IntersectionObserver
.
These are the scripts to evaluate the performance of the both methods:
const elements = document.querySelectorAll(".element");
// `getBoundingClientRect()`
for (const element of elements) {
const bounds = element.getBoundingClientRect();
}
// `IntersectionObserver`
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const bounds = entry.boundingClientRect;
}
observer.disconnect();
});
for (const element of elements) {
observer.observe(element);
}
When using getBoundingClientRect()
results without any further manipulation, everything runs pretty fast. Check the live demo to see how it performs in your browser.
When using IntersectionObserver
in this live demo everything is fast, too. It seems there is no big difference until you check the Performance tab in Google Chrome tools. When running getBoundingClientRect()
, the browser is forced to do a reflow and it takes longer to evaluate the script.
On the other hand, using IntersectionObserver
makes no reflows, and the script runs as fast as possible. Take to count that the page has 5000 elements, so parsing and recalculating styles take more time in both cases.
Even that the first method is not as fast as the second, the performance hit is not so obvious. But what if you need to display the element's dimensions somewhere.
This example shows what happens when we want to display the bounds on each element as text content using CSS ::after
pseudo-element.
But first, let's edit the code a little bit and add a line that sets a data-bounds
attribute on the element.
const elements = document.querySelectorAll(".element");
// `getBoundingClientRect()`
for (const element of elements) {
const bounds = element.getBoundingClientRect();
}
// `IntersectionObserver`
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
const bounds = entry.boundingClientRect;
}
observer.disconnect();
});
for (const element of elements) {
observer.observe(element);
}
The results are shocking. While the IntersectionObserver
method looks like there's no difference, the getBoundingClientRect()
method got mad. It takes 1.14s to evaluate the script and makes a huge amount of reflows.
OK, someone can argue that this is because the IntersectionObserver
runs in asynchronous mode. It's true, so let's make the getBoundingClientRect()
asynchronous with this script:
const promises = [];
async function loop() {
for (const element of elements) {
let bounds = await element.getBoundingClientRect();
promises.push(bounds);
}
Promise.all(promises).then((results) => {
for (const [i, element] of Object.entries(elements)) {
let result = results[Number(i)];
element.dataset.bounds = `x: ${result.x} y:${result.y} width: ${result.width} height: ${result.height}`;
}
});
}
loop();
The results are much better compared with synchronous method. There are magically no reflows, but the script evaluation time is still longer then IntersectionObserver
As you can see, the IntersectionObserver
can be used not just to check the element visibility, but also to calculate its dimensions and position. Compared to getBoundingClientRect()
it's faster and doesn't produce any reflows. Even when the getBoundingClientRect()
is used in asynchronous function, it's still slower.
In the Torus Kit, we're using this approach to get element bounds as fast as possible without unnecessary reflows.
12