Editor’s note: This post was updated on 5 November 2024 by Rahul Chhodde to include updates about new features in the ResizeObserver API, Vue.js version 3.3, Popper.js v2.11.8, Chrome Canary, and container query support in CSS.
Recently, I was presented with a challenging design at work: a component with a row of buttons across the top. The catch was that whenever the component was not wide enough to fit all the buttons, those actions needed to move into a dropdown menu.
Building a UI that can adapt to both varying screen widths and varying container widths is a challenge that has become more common with the growing popularity of component-based frameworks such as React and Vue.js, as well as native web components. The same component may need to work in both a wide main content area and within a narrow side column — across all devices, no less.
ResizeObserver
?ResizeObserver
in JavaScript is a web API that allows you to watch changes in the size or dimensions of any given element in the DOM whenever it is resized for whatever reason.
We can easily set up a ResizeObserver
instance by providing it with an observer callback, and then observe the changes using the observe
method as shown in the below code:
const onResize = (entries) => { /* Things to do when resizing is detected */ } const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe( document.querySelector(".target-element") );
As shown in the above code, an instance of ResizeObserver
takes a handler function as an argument, which utilises an array of ResizeObserverEntry
objects (entries
in this case). Each of these objects provides the updated dimensions of their respective entry (element) through three primary properties:
borderBoxSize
: Returns the full size of the observed entry including its width, height, borders, and padding; handy for cases needing the full size of the elementcontentBoxSize
: Provides only the size of the content box, considers only width and height and excludes borders and padding; useful when only the inner content’s size is requireddevicePixelContentBoxSize
: Represent the size of the content box in physical device pixels; useful to get pixel-precise info on high-DPI screensThese three properties provide granular handling of responsive layout adjustments based on the observed element’s resizing details. Here’s a quick glimpse into using these properties through the API:
const onResize = (entries) => { entries.forEach(entry => { const { inlineSize: borderWidth, blockSize: borderHeight } = entry.borderBoxSize[0]; console.log(`Border box - Width: ${borderWidth}px, Height: ${borderHeight}px`); const { inlineSize: contentWidth, blockSize: contentHeight } = entry.contentBoxSize[0]; console.log(`Content box - Width: ${contentWidth}px, Height: ${contentHeight}px`); if (entry.devicePixelContentBoxSize) { const { inlineSize: deviceContentWidth, blockSize: deviceContentHeight } = entry.devicePixelContentBoxSize[0]; console.log(`Device pixel content box - Width: ${deviceContentWidth}px, Height: ${deviceContentHeight}px`); } }); }; const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(document.querySelector(".target-element"));
Here’s a simple implementation to show these three properties in action. Try resizing the highlighted element in the demo below and see how these properties help in fetching size updates:
See the Pen
ResizeObserverEntry properties in action by Rahul (@_rahul)
on CodePen.
The ResizeObserver API is a great tool for creating UIs that can adapt to the user’s screen and container width. Using a ResizeObserver
, we can call a function whenever an element is resized, much like listening to a window resize
event.
The use cases for ResizeObserver
may not be immediately obvious, so let’s take a look at a few practical examples.
For our first example, imagine you want to show a row of random inspirational photos below the hero section of your page. You only want to load as many photos as are needed to fill that row, and you want to add or remove photos as necessary whenever the container width changes.
We could leverage resize
events, but perhaps our component’s width also changes whenever a user collapses a side panel. That’s where ResizeObserver
comes in handy.
See the Pen
ResizeObserver – Fill Container by Kevin Drum (@kevinleedrum)
on CodePen.
Looking at our JavaScript for this example, the first couple of lines set up our observer:
const resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(document.querySelector(".container"));
We create a new ResizeObserver
, passing a callback function to the constructor. We then tell our new observer which element to observe.
Keep in mind that it is possible to observe multiple elements with a single observer if you ever encounter the need.
After that, we get to the core logic of our UI:
const IMAGE_MAX_WIDTH = 200; const IMAGE_MIN_WIDTH = 100; function onResize(entries) { const entry = entries[0]; const container = entry.target; /* Calculate how many images can fit in the container. */ const imagesNeeded = Math.ceil(entry.contentRect.width / IMAGE_MAX_WIDTH); let images = container.children; /* Remove images as needed. */ while (images.length > imagesNeeded) { images[images.length - 1].remove(); } /* Add images as needed. */ while (images.length < imagesNeeded) { let seed = Math.random().toString().replace(".", ""); const newImage = document.createElement("div"); const imageUrl = `https://picsum.photos/seed/${seed}/${IMAGE_MAX_WIDTH}`; newImage.style.backgroundImage = `url(${imageUrl})`; container.append(newImage); } }
After defining the minimum and maximum widths for our images (so they can fill the entire width), we declare our onResize
callback. The ResizeObserver
passes an array of ResizeObserverEntry
objects to our function.
Because we are only observing one element, our array only contains one entry. That entry object provides the new dimensions of the resized element (via the contentRect
property), as well as a reference to the element itself (the target
property).
Using our updated element’s new width, we can calculate how many images should be shown and compare that to the number of images already shown (the container element’s children
). After that, it’s as simple as removing elements or adding new elements.
For demonstration purposes, I’m showing random images from Lorem Picsum.
Our second example addresses a problem that is fairly common: changing a flex row of elements into a column whenever those elements won’t fit in a single row (without overflowing or wrapping).
With the ResizeObserver
API, this is totally possible.
See the Pen
ResizeObserver – Flex Direction by Kevin Drum (@kevinleedrum)
on CodePen.
Our onResize
function in this example looks like this:
let rowWidth; function onResize(entries) { // Get the first entry from the observer entries const entry = entries[0]; const container = entry.target; // The element being observed for resize // Calculate row width if it hasn't been set yet if (!rowWidth) rowWidth = Array.from(container.children).reduce( (acc, el) => getElWidth(el) + acc, // Accumulate the width of each child element 0 // Initial value for the accumulator ); // Check if the total row width exceeds the container's current width const isOverflowing = rowWidth > entry.contentRect.width; // If the content is overflowing and the container is not vertical if (isOverflowing && !container.classList.contains("container-vertical")) { // Use requestAnimationFrame to ensure smooth update in the next frame requestAnimationFrame(() => { container.classList.add("container-vertical"); // Add vertical layout class }); } // If content is no longer overflowing and the container is in vertical layout else if (!isOverflowing && container.classList.contains("container-vertical")) { // Remove the vertical layout class in the next frame for a smooth transition requestAnimationFrame(() => { container.classList.remove("container-vertical"); }); } }
The function sums up the widths of all the buttons, including margin, to figure out how wide the container needs to be to show all the buttons in a row. We’re caching this calculated width in a rowWidth
variable that is scoped outside our function so we don’t waste time calculating it every time the element is resized.
Once we know the minimum width needed for all the buttons, we can compare that to the new container width and transform the row into a column if the buttons won’t fit. To achieve that, we’re simply toggling a container-vertical
class on the container.
Some of the problems that require ResizeObserver
can be solved much more efficiently with the CSS container queries feature, which is now widely supported across modern browsers. However, due to CSS’s declarative and presentational nature, container queries always require us to specify a size property like min-width
, aspect-ratio
, etc. to add only CSS styles to the containers.
ResizeObserver
, on the other hand, gives us unlimited power to examine the entire DOM more precisely for multiple elements and write logic as complex as we want. Plus, it is already supported in all the major browsers.
Remember that work problem I mentioned where I needed to responsively move buttons into a dropdown menu? Our final example is very similar.
Conceptually, this example builds upon the previous example because we are once again checking to see when we are overflowing a container. In this case, we need to repeat that check every time we remove a button to see if we need to remove yet another button.
To reduce the amount of boilerplate, I am using Vue.js for this example, though the idea should work for any framework. I’m using Vue 3 (v3.5.12) and implementing Vue’s composition API in this example. I’m also using Vue’s SFC (Single-File Component) format to keep the template, script, and style in a single file for our codepen demo. I’m also using Popper.js (v2.11.8) to position the dropdown menu.
See the Pen
ResizeObserver – Responsive Toolbar (Vue 3) by Rahul (@_rahul)
on CodePen.
There is quite a bit more code for this example, but we’ll break it down. All of our logic lives inside a Vue instance (or component):
<script> let resizeObserver; let popperInstance; export default { setup() { const { ref, computed, onMounted, onBeforeUnmount, nextTick } = Vue; // Refs for template elements const container = ref(null); const menuButton = ref(null); const menu = ref(null); // State const actions = ref(["Edit", "Save", "Copy", "Rename", "Share", "Delete"]); const isMenuOpen = ref(false); const menuActions = ref([]); } }; </script>
We have three important data properties that comprise the “state” of our component.
actions
array lists all the actions we need to show in our UIisMenuOpen
boolean is a flag we can toggle to show or hide the action menumenuActions
array will hold a list of actions that should be shown in the menu (when there isn’t enough space to show them as buttons)We will update this array as needed in our onResize
callback, and our HTML will then automatically update.
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... // Computed const actionButtons = computed(() => { return actions.value.filter( (action) => !menuActions.value.includes(action) ); }); } };</script>
We’re using a Vue computed property named actionButtons
to generate an array of actions that should be shown as buttons. It’s the inverse of menuActions
.
With these two arrays, our HTML template can simply iterate them both to create the buttons and menu items, respectively:
<template> <div ref="container" class="container"> <!-- Action buttons --> <button v-for="action in actionButtons" :key="action" @click="doAction(action)" > {{ action }} </button> <!-- Menu button --> <button ref="menuButton" v-show="menuActions.length" @click.stop="toggleMenu" > … </button> <!-- Action menu items --> <div ref="menu" v-show="isMenuOpen" class="menu"> <button v-for="action in menuActions" :key="action" @click="doAction(action)" > {{ action }} </button> </div> </div> <!-- More elements can be added here --> </template>
If you’re not familiar with Vue template syntax, don’t sweat it too much. Just know that we’re dynamically creating buttons and menu items with click
event handlers from those two arrays, and we’re showing or hiding a dropdown menu based on that isMenuOpen
boolean.
The ref
attributes also allow us to access those elements from our script without having to use a querySelector
.
Vue provides a couple of lifecycle methods that enable us to set up our observer when the component first loads and clean it up whenever our component is destroyed:
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... // Lifecycle hooks onMounted(() => { resizeObserver = new ResizeObserver(onResize); resizeObserver.observe(container.value); document.addEventListener("click", closeMenu); }); onBeforeUnmount(() => { if (resizeObserver) { resizeObserver.disconnect(); } document.removeEventListener("click", closeMenu); }); } };</script>
Now comes the fun part, which is our onResize
method:
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... // Callback handler for ResizeObserver const onResize = () => { requestAnimationFrame(async () => { // Place all buttons outside the menu if (menuActions.value.length) { menuActions.value = []; await nextTick(); } const isOverflowing = () => container.value.scrollWidth > container.value.offsetWidth; // Move buttons into the menu until the container is no longer overflowing while (isOverflowing() && actionButtons.value.length) { const lastActionButton = actionButtons.value[actionButtons.value.length - 1]; menuActions.value.unshift(lastActionButton); await nextTick(); } }); }; } };</script>
The first thing you may notice is that we’ve wrapped everything in a call to requestAnimationFrame
. This simply throttles how often our code can run (typically 60 times per second). This helps avoid ResizeObserver loop limit exceeded
console warnings, which can happen whenever your observer callback tries to run multiple times during a single animation frame.
With that out of the way, our onResize
methods begin by resetting to a default state if necessary. The default state is when all of the actions are represented by buttons, not menu items.
As part of this reset, it awaits a call to this.$nextTick
, which tells Vue to go ahead and update its virtual DOM, so our container element will be back to its max width with all the buttons showing.
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... // Callback handler for ResizeObserver const onResize = () => { requestAnimationFrame(async () => { // Place all buttons outside the menu if (menuActions.value.length) { menuActions.value = []; await nextTick(); } // Other code blocks }); }; } };</script>
Now that we have a full row of buttons, we need to check whether the row is overflowing so we know if we need to move any of the buttons into our action menu.
A simple way to identify whether an element is overflowing is to compare its scrollWidth
to its offsetWidth
. If the scrollWidth
is greater, then the element is overflowing.
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... const onResize = () => { requestAnimationFrame(async () => { // Previous code ... const isOverflowing = () => this.$refs.container.scrollWidth > this.$refs.container.offsetWidth; // Other code blocks }); }; } };</script>
The rest of our onResize
method is a while
loop. During every iteration, we check to see whether the container is overflowing and, if it is, we move one more action into the menuActions
array. The loop only breaks once we’re no longer overflowing the container, or once we’ve moved all the actions into the menu.
Notice that we’re awaiting this.$nextTick()
after each loop so the container’s width is allowed to update after the change to this.menuActions
.
<script> let resizeObserver; let popperInstance; export default { setup() { // Previous code ... const onResize = () => { requestAnimationFrame(async () => { // Previous code ... // Move buttons into the menu until the container is no longer overflowing while (isOverflowing() && actionButtons.value.length) { const lastActionButton = actionButtons.value[actionButtons.value.length - 1]; menuActions.value.unshift(lastActionButton); await nextTick(); } }); }; } };</script>
That encompasses all the magic we needed to conquer this challenge. Much of the rest of the code in our Vue component is related to the behavior of the dropdown menu, which is outside the scope of this article.
Hopefully, these examples highlight the usefulness of the ResizeObserver
API, particularly in component-based approaches to frontend development. Alongside CSS media queries, container queries, and resize
events, it helps build the foundation of dynamic, interactive, and responsive interfaces on the modern web.
Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.
LogRocket allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.
LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]