Skip to main content
kld.dev

ResizeObserver API: a tutorial with examples

September 1, 2021

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.

What is ResizeObserver?

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.

Filling a container

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.

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.

Changing a flex row to a column

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.

Our onResize function in this example looks like this:

let rowWidth;

function onResize(entries) {
  const entry = entries[0];
  const container = entry.target;
  if (!rowWidth)
    rowWidth = Array.from(container.children).reduce((acc, el) => getElWidth(el) + acc, 0);
  const isOverflowing = rowWidth > entry.contentRect.width;
  if (isOverflowing && !container.classList.contains('container-vertical')) {
    requestAnimationFrame(() => {
      container.classList.add('container-vertical');
    });
  } else if (!isOverflowing && container.classList.contains('container-vertical')) {
    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.

What about container queries?

Some of the problems that can be solved with ResizeObserver can be solved much more efficiently with CSS container queries, which are now supported in Chrome Canary. However, one drawback to container queries is that they require known values for min-width, aspect-ratio, etc.

ResizeObserver, on the other hand, gives us unlimited power to examine the entire DOM and write logic as complex as we want. Plus, it is already supported in all the major browsers.

Responsive toolbar component

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 also using Popper to position the dropdown menu.

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

new Vue({
  el: "#app",
  data() {
    return {
      actions: ["Edit", "Save", "Copy", "Rename", "Share", "Delete"],
      isMenuOpen: false,
      menuActions: [] // Actions that should be shown in the menu
    };
  },

We have three important data properties that comprise the “state” of our component.

  • The actions array lists all the actions we need to show in our UI
  • The isMenuOpen boolean is a flag we can toggle to show or hide the action menu
  • The menuActions 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.

  computed: {
    actionButtons() {
      // Actions that should be shown as buttons outside the menu
      return this.actions.filter(
        (action) => !this.menuActions.includes(action)
      );
    }
  },

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:

<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">&hellip;</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>

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:

  mounted() {
    // Attach ResizeObserver to the container
    resizeObserver = new ResizeObserver(this.onResize);
    resizeObserver.observe(this.$refs.container);
    // Close the menu on any click
    document.addEventListener("click", this.closeMenu);
  },
  beforeDestroy() {
    // Clean up the observer and event listener
    resizeObserver.disconnect();
    document.removeEventListener("click", this.closeMenu);
  },

Now comes the fun part, which is our onResize method:

  methods: {
  onResize() {
    requestAnimationFrame(async () => {
      // Place all buttons outside the menu
      if (this.menuActions.length) {
        this.menuActions = [];
        await this.$nextTick();
      }

      const isOverflowing = () =>
        this.$refs.container.scrollWidth > this.$refs.container.offsetWidth;

      // Move buttons into the menu until the container is no longer overflowing
      while (isOverflowing() && this.actionButtons.length) {
        const lastActionButton = this.actionButtons[
          this.actionButtons.length - 1
        ];
        this.menuActions.unshift(lastActionButton);
        await this.$nextTick();
      }
    });
  },

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.

// Place all buttons outside the menu
if (this.menuActions.length) {
  this.menuActions = [];
  await this.$nextTick();
}

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.

const isOverflowing = () => this.$refs.container.scrollWidth > this.$refs.container.offsetWidth;

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.

// Move buttons into the menu until the container is no longer overflowing
while (isOverflowing() && this.actionButtons.length) {
  const lastActionButton = this.actionButtons[this.actionButtons.length - 1];
  this.menuActions.unshift(lastActionButton);
  await this.$nextTick();
}

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.

Conclusion

Hopefully, these examples highlight the usefulness of the ResizeObserver API, particularly in component-based approaches to frontend development. Alongside CSS media queries, the up-and-coming container queries, and resize events, it helps build the foundation of responsive interfaces on the modern web.