Resize Observer API with Stimulus

By Exequiel Rozas

- April 07, 2025

Occasionally, parts of our application that depend on the size of the screen can break because of the user resizing the browser window.

The browser includes features like CSS media queries or container queries. But at times, they are not enough to achieve what we want. That's where the Resize Observer API comes into play.

In this article, we will cover what it is, how to use the resize observer with Stimulus and show some examples that might be useful for you when building your next application.

Let's start by learning what it is. If you're not interested in that, you can jump straight to using the API with Stimulus.

What's the Resize Observer

The Resize Observer API is a modern JS API that allows us to observe and respond to changes in the size of an element's content or border box.

Compared to previous solutions that required us to add an event listener to the browser's window even if we actually wanted to track the size of a specific element within the window:

window.addEventListener("resize", (evt) => {
  // Perform resize related operations triggered by the browser window resizing
});

The Resize Observer API is a more efficient way to track element dimensions and operate based on changes to them.

Some common uses for this API are:

  • Layout adjustments based on an element's size: by observing the resizing of a given element, we can make layout adjustments to avoid breaking our layouts or make them appear in a less than ideal way.
  • Size changes caused by added elements: the Resize Observer API can help us react to size changes of an element that are triggered because of the dynamic addition or removal of elements. These changes trigger the observer API but don't trigger with the previous global resize event listener.
  • Adaptive components: we can adjust the way we render things, conditionally hiding and showing elements or adjusting their size or style depending on the size of the element we're observing. This can also be achieved with CSS and media queries, but with the Resize Observer API we can make it more dynamic to avoid depending on breakpoints, which can lead to some unexpected behavior if not properly taken care of.
  • Scalable content resizing: if we have a component that needs to be scaled depending on its container or the browser's window size, we can use the Resize Observer API to perform the appropriate calculations and apply the changes to the content.

The reason behind the Resize Observer API being preferable to adding an event listener to the window object is that the former allows us to make changes when a specific element changes, avoiding the need to listen for changes on the global window. Unless we actually want to listen specifically to the window changes, this makes the observer much more efficient as it avoids unnecessary evaluations.

How it works

Just like other JavaScript APIs, the Resize Observer is an implementation of the observer pattern that works on the browser and reacts to changes within it.

To explain how it works, we will create a layout with two resizable boxes that are next to each other, and we will perform changes on them based on a specific condition.

Let's start by defining the HTML structure using Tailwind:

<div class="flex items-start gap-x-4 px-6 py-8">
  <div id="box-one" class="w-96 h-72 p-3 min-w-12 max-w-full rounded-md bg-rose-200 relative resize-x overflow-auto" >
    <h2 class="font-bold text-zinc-700">Box 1</h2>
  </div>
  <div id="box-two" class="w-96 h-72 p-3 min-w-12 max-w-full rounded-md bg-yellow-200 relative resize-x overflow-auto">
    <h2 class="font-bold text-zinc-700">Box 2</h2>
  </div>
</div>

This will render the following:

Resize observer example layout

Now, we will use the Resize Observer within a <script> tag to log the resized entry, just to show how it works:

<script>
  document.addEventListener('turbo:load', function() {
    const boxOne = document.getElementById('box-one');
    const boxTwo = document.getElementById('box-two');

    const observer = new ResizeObserver((entries) => {
      entries.forEach((entry) => {
        console.log(entry);
      })
    })

    observer.observe(boxOne);
    observer.observe(boxTwo);
  });
</script>

We first create a new instance of a ResizeObserver where we pass a callback that receives the entries which are instances of the ResizeObserverEntry class.

Then, we call the observe method of the observer passing each element we want to observe.

In this case, whether we resize the sidebar or the mainContent, the element will be attached to the ResizeObserverEntry instance, which will have the following properties:

  • target: represents the DOM element that was resized.
  • borderBoxSize: it returns an array of objects that contain the new border box size of the observed element. This means that these measurements include the content padding and borders of the element.
  • contentBoxSize: it also returns an array of objects but with the content box size of the observed element. It doesn't take borders and padding into consideration.
  • contentRect: returns a DOMRectReadOnly object which contains the x, y properties which represent the coordinates of the element's origin, the width and height properties and the top, right, bottom and left properties which represent the corresponding coordinates for the rectangle.
  • devicePixelContentBoxSize: returns the same as the contentBoxSize but using the device pixel display unit, which is a density-dependent unit.

Notably, the width and height of the element for horizontal writing modes are represented as inlineSize for the width and block Size for the height. Those are the keys of the object returned by the borderBoxSize, contentBoxSize and devicePixelContentBoxSize.

Now that we know what the callback function does and how to set the observer, let's see a couple of use cases using Stimulus.

Using it with Stimulus

Even though there's nothing particularly specific about integrating the Resize Observer API with Stimulus, there are a couple of things to consider.

Let's start by showing a basic use case:

Change color depending on relative width

The first thing we will do is to observe the resizing of the boxes and change their colors whenever one of them has passed double the width of the other.

Let's see how we could implement that with Stimulus:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["boxOne", "boxTwo", "boxOneText", "boxTwoText"]

  connect() {
    this.resizeObserver = new ResizeObserver(this.handleResize.bind(this));
    this.resizeObserver.observe(this.boxOneTarget);
    this.resizeObserver.observe(this.boxTwoTarget);
  }

  disconnect() {
    this.resizeObserver.disconnect();
  }

  handleResize(entries) {
    const boxOne = this.boxOneTarget;
    const boxTwo = this.boxTwoTarget;

    this.updateBoxText();

    const boxOneWidth = boxOne.clientWidth;
    const boxTwoWidth = boxTwo.clientWidth;

    if (boxOneWidth > boxTwoWidth * 2) {
      boxOne.classList.remove("bg-rose-200");
      boxOne.classList.add("bg-blue-200");
    } else {
      boxOne.classList.remove("bg-blue-200");
      boxOne.classList.add("bg-rose-200");
    }

    if (boxTwoWidth > boxOneWidth * 2) {
      boxTwo.classList.remove("bg-yellow-200");
      boxTwo.classList.add("bg-emerald-200");
    } else {
      boxTwo.classList.remove("bg-emerald-200");
      boxTwo.classList.add("bg-yellow-200");
    }
  }

  updateBoxText() {
    this.boxOneTextTarget.textContent = `${this.boxOneTarget.clientWidth}x${this.boxOneTarget.clientHeight}`;
    this.boxTwoTextTarget.textContent = `${this.boxTwoTarget.clientWidth}x${this.boxTwoTarget.clientHeight}`;
  }
}

This produces the following result:

Of course, this is not the most elegant Stimulus controller, we just want to show you how the resize observer gives us the ability to make changes depending on how other elements resize.

Now that we've seen how it's used with Stimulus, let's build a couple of examples that are more realistic:

If you followed our dynamic table of contents with Rails tutorial, you might have noticed the use of the Intersection Observer API, which works similarly to the Resize Observer and has the same performance advantage vs. using alternatives like attaching an event listener to the scroll event on the document or the window objects.

Keep height on horizontal containers

A common scenario that we might encounter, especially when building a SaaS application like Avo Admin, are multi-column layouts that don't keep the same desired height as we resize the window.

We can solve the height issue using CSS grid by making each column occupy the full height of the container using h-full
in Tailwind or height: 100% with CSS.

Having those containers the same height is a nice start but doesn't solve the differing heights that usually come where the features are outlined:

Unequal height issue with pricing page

One way to solve this would be to add a fixed height to the feature containing day and call it a day:

Fixed height for the feature containing div

It looks better, but we still have one issue: because the “Enterprise” option has more text, it will overflow and grow beyond our current fixed height at some point of the viewport size:

Fixed height content overflow

Yes, we could fix this with a media query, but at some point some more features might be added here and there and break our site.

The HTML structure is the following:

<div class=" max-w-screen-xl mx-auto sm:px-6 md:px-8 py-8 mt-6">
  <div class="grid grid-cols-12 gap-x-4 gap-y-8" data-controller="pricing">
    <div class="col-span-12 md:col-span-4">
      <div class="bg-white h-full border border-neutral-200 rounded-md shadow-sm p-6">
        <h2 class="text-lg font-medium">Starter</h2>
        <p class="text-gray-600 mb-4">For personal use</p>
        <div class="flex items-center gap-x-2 mb-4">
          <span class="text-2xl font-bold">$10</span>
          <span class="text-sm text-gray-500">/month</span>
        </div>
        <ul class="space-y-2 h-auto" data-pricing-target="features">
          <li>
            <span class="text-gray-600">100 uploaded images</span>
          </li>
          <li>
            <span class="text-gray-600">Up 10 600 edits per month</span>
          </li>
          <li>
            <span class="text-gray-600">120GB of bandwidth</span>
          </li>
          <li>
            <span class="text-gray-600">20 GB of storage</span>
          </li>
        </ul>
        <button class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
          Get Started
        </button>
      </div>
    </div>
    <div class="col-span-12 md:col-span-4">
      <div class="bg-white h-full border border-neutral-200 rounded-md shadow-sm p-6">
        <h2 class="text-lg font-medium mb-2">Pro</h2>
        <p class="text-gray-600 mb-4">For professional use</p>
        <div class="flex items-center gap-x-2 mb-4">
          <span class="text-2xl font-bold">$70</span>
          <span class="text-sm text-gray-500">/month</span>
        </div>
        <ul class="space-y-2 h-auto" data-pricing-target="features">
          <li>
            <span class="text-gray-600">1000 uploaded images</span>
          </li>
          <li>
            <span class="text-gray-600">Up to 2000 edits per month</span>
          </li>
          <li>
            <span class="text-gray-600">1TB of bandwidth</span>
          </li>
          <li>
            <span class="text-gray-600">200 GB of storage</span>
          </li>
          <li>
            <span class="text-gray-600">Priority support</span>
          </li>
        </ul>
        <button class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
          Get Started
        </button>
      </div>
    </div>
    <div class="col-span-12 md:col-span-4">
      <div class="bg-white h-full border border-neutral-200 rounded-md shadow-sm p-6">
        <h2 class="text-lg font-medium mb-2">Enterprise</h2>
        <p class="text-gray-600 mb-4">For enterprise use</p>
        <div class="flex items-center gap-x-2 mb-4">
          <span class="text-2xl font-bold">$100</span>
          <span class="text-sm text-gray-500">/month</span>
        </div>
        <ul class="space-y-2 h-auto" data-pricing-target="features">
          <li>
            <span class="text-gray-600">10000 uploaded images</span>
          </li>
          <li>
            <span class="text-gray-600">Up to 7500 edits per month</span>
          </li>
          <li>
            <span class="text-gray-600">Up to 1.2 TB of bandwidth</span>
          </li>
          <li>
            <span class="text-gray-600">2000 GB of storage</span>
          </li>
          <li>
            <span class="text-gray-600">Global CDN</span>
          </li>
          <li>
            <span class="text-gray-600">Priority support with 1 hour response time</span>
          </li>
        </ul>
        <button class="mt-4 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors">
          Get Started
        </button>
      </div>
    </div>
  </div>
</div>

Now, let's see how to fix this using the Resize Observer:

// app/javascript/controllers/pricing_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["features"]

  connect() {
    this.resizeObserver = new ResizeObserver(entries => {
      this.equalizeFeatureHeights();
    });

    this.featuresTargets.forEach(feature => {
      this.resizeObserver.observe(feature);
    });

  }

  disconnect() {
    if (this.resizeObserver) {
      this.resizeObserver.disconnect();
      this.featuresTargets.forEach(feature => {
        this.resizeObserver.unobserve(feature);
      });
    }
  }

  equalizeFeatureHeights() {
    this.featuresTargets.forEach(feature => {
      feature.style.height = 'auto';
    });

    let maxHeight = 0;
    this.featuresTargets.forEach(feature => {
      maxHeight = Math.max(maxHeight, feature.offsetHeight);
    });

    this.featuresTargets.forEach(feature => {
      feature.style.height = `${maxHeight + 6}px`;
    });
  }
}

What the code does is declare a resize observer, which receives the this.equalizeFeatureHeights() method to be executed every time the observer detects a resize on any featuresTarget.

The equalizeFeatureHeights gets the max height for every feature container and then sets that height for every one of them.

The result looks like this:

Adaptive chart

Another good use case for the API is to generate adaptive charts that change the amount of data they display depending on its container size.

For example, with enough width, we will show 12 months of sales, and otherwise we will show quarterly sales.

It uses the observer because it recalculates the data aggregations, changes the chart size using the charting library and updates the UI based on the chart state.

Let's start by installing Chart.js:

$ yarn add chart.js

Or, if you're using importmap:

$ ./bin/importmap pin chart.js

Then, we will add some hardcoded sample data:

# app/models/sales_data.rb
class SalesData
  attr_reader :monthly_data

  def initialize
    @monthly_data = [
      { month: 'Jan', sales: 12500, target: 11000, year: 2024 },
      { month: 'Feb', sales: 15000, target: 14000, year: 2024 },
      { month: 'Mar', sales: 18200, target: 16000, year: 2024 },
      { month: 'Apr', sales: 22000, target: 20000, year: 2024 },
      { month: 'May', sales: 24800, target: 23000, year: 2024 },
      { month: 'Jun', sales: 30500, target: 25000, year: 2024 },
      { month: 'Jul', sales: 28400, target: 27000, year: 2024 },
      { month: 'Aug', sales: 27000, target: 26000, year: 2024 },
      { month: 'Sep', sales: 32100, target: 28000, year: 2024 },
      { month: 'Oct', sales: 35400, target: 30000, year: 2024 },
      { month: 'Nov', sales: 38200, target: 32000, year: 2024 },
      { month: 'Dec', sales: 42000, target: 35000, year: 2024 }
    ]
  end

  def monthly
    @monthly_data
  end

  def quarterly
    [
      { period: 'Q1', sales: 15233, target: 13667 },
      { period: 'Q2', sales: 25767, target: 22667 },
      { period: 'Q3', sales: 29167, target: 27000 },
      { period: 'Q4', sales: 38533, target: 32333 }
    ]
  end

  def biannual
    [
      { period: 'H1', sales: 20500, target: 18167 },
      { period: 'H2', sales: 33850, target: 29667 }
    ]
  end

  def annual
    [
      { period: 'Y1', sales: 27175, target: 23917 }
    ]
  end

  def to_json
    {
      monthly: monthly,
      quarterly: quarterly,
      biannual: biannual,
      annual: annual
    }.to_json
  end
end

Next, we will add the HTML to render the dashboard:

<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
  <h1 class="text-3xl font-bold text-gray-900 mb-8">Sales Dashboard</h1>

  <div class="bg-white rounded-lg border border-neutral-200 px-8 py-6 mb-8" 
       data-controller="adaptive-chart"
       data-adaptive-chart-data-value="<%= @sales_data.to_json %>">

    <div class="flex flex-wrap justify-between items-center mb-4">
      <h2 class="text-xl font-semibold text-gray-800">Sales Performance (USD)</h2>
    </div>

    <div class="relative h-96 mb-4">
      <canvas data-adaptive-chart-target="chart"></canvas>
    </div>
  </div>
</div>

Then, we will define a Stimulus controller to display a responsive chart with the monthly data:

// app/javascript/controllers/adaptive_chart_controller.js
import { Controller } from "@hotwired/stimulus"
import Chart from "chart.js/auto"

export default class extends Controller {
  static targets = ["chart"]
  static values = { data: Object }

  connect() {
    this.chart = null
    this.currentMode = "monthly"

    if (this.hasDataValue) {
      this.initChart()
    }
  }

  disconnect() {
    this.chart.destroy()
  }

  initChart() {
    const ctx = this.chartTarget.getContext('2d')
    this.chart = new Chart(ctx, this._options())
  }

  _options() {
    const monthlyData = this.dataValue.monthly

    return {
      type: 'line',
      data: {
        labels: monthlyData.map(item => item.month),
        datasets: [
          {
            label: '',
            data: monthlyData.map(item => item.sales),
            backgroundColor: 'rgba(59, 130, 246, 0.2)',
            borderColor: 'rgb(59, 130, 246)',
            borderWidth: 3,
            tension: 0.4,
            fill: true
          }
        ]
      },
      options: {
        responsive: true,
        maintainAspectRatio: false,
        plugins: {
          legend: {
            display: false
          }
        },
      }
    } 
  }
}

The following chart should display:

Initial render of the responsive chart with monthly sales

Everything's working as expected, however if we resize the chart we get a smaller version of the same graphic, which is better than nothing, but we can do better: we will adapt the number of months whenever the screen resizes below a certain point:

Sales chart on mobile displaying 12 months of sales

To achieve what we want, we will observe the resizing of the <div> where the controller's declared:

initChart() {
  const ctx = this.chartTarget.getContext('2d')
  this.chart = new Chart(ctx, this._options())

  this.resizeObserver = new ResizeObserver(entries => {
    entries.map((entry) => {
      const width = entry.contentRect.width
      this._updateChartForWidth(width)
    })
  })

  this.resizeObserver.observe(this.element)

  const initialWidth = this.element.clientWidth;
  this._updateChartForWidth(initialWidth)
}

What's happening here is that we're assigning a new resize observer to the this.resizeObserver instance variable and having the callback we pass to it as an argument get the width from the ResizeObserverEntry it receives.

We call observe passing this.element which is the controller's container.

Finally, the core of the action will happen inside this._updateChartForWidth function which is also executed when the chart's initialized, even before any manual resizing:

_updateChartForWidth(width) {
  if (!this.chart || !this.hasDataValue) return

  let newMode, data

  if (width < 600) {
    newMode = "biannual"
    data = this.dataValue.biannual
  } else if (width < 720) {
    newMode = "quarterly"
    data = this.dataValue.quarterly
  } else {
    newMode = "monthly"
    data = this.dataValue.monthly
  }

  if (newMode !== this.currentMode) {
    this.currentMode = newMode

    this.chart.data.labels = data.map(item => item.period || item.month)
    this.chart.data.datasets[0].data = data.map(item => item.sales)
    this.chart.update()
  }
}

Here we declare a newMode to avoid making a change in the chart unless the variable changes.

The newMode value is always equals to one of the keys of JSON object we passed to the view by calling @sales_data.to_json.

Finally, we set the labels and the chart data itself using the sales data before calling this.chart.update() which updates the chart.

The result looks like this:

Don't forget to call this.resizeObserver.disconnect() on the disconnect method of the controller. Otherwise, we can experiment performance issues because of memory leaks.

Audio player waveform resizing

This is probably a not so typical use case for the Resize Observer API, but I have used it in the past and I found it interesting.

You might have seen those audio players that feature a progress bar that's on top of a waveform that simulates the audio that's being played:

Audio player with sound waveforms

Even though it might seem like the waveform represents the audio, they are just bars which height is interpolated between some values that make it seem like it represent the audio for an improved visual experience.

But there's an issue with the bars, if we were to make the player smaller, the bars would get too close together and look worse.

To avoid making this article too long, we explain how to implement an audio player just like this in our making an audio player with Stimulus article, but it's actually using the resize observer which triggers the function that recalculates the bar.

The final result looks like this:

Resize Observer vs. media and container queries

The main differences between the Resize Observer and media queries is that media queries fire when the viewport size changes, but occasionally, we need to apply changes when a specific element or container is resized.

However, the new CSS Container Queries feature allows us to customize the CSS for an element based on the size of another element.

This means that there's some overlap between Container Queries and the Resize Observer.

However, there are a couple of things to consider:

  • Container queries are more recent and current browser support for the Resize Observer is better.
  • If we need to perform calculations and change the layout of a given element based on the size of another element, the Resize Observer API is the correct choice.

Current Container Queries Browser Support

At the time of writing this, March 2025, the global support for CSS Container Queries is around 93%, while the support for the Resize Observer API is a bit higher at around 96%:

Resize Observer API support

With that said, there are probably many cases previously solved in the Resize Observer API where container queries can entirely replace it without any noticeable changes.

Lastly, if you need to support the RO API for older browsers, you can use the resize observer polyfill.

Resize Observer best practices

The Resize Observer can be considered more efficient than the previous way to implement resize-dependent features.

However, we have to be careful about its performance and lifecycle to avoid issues.

When using it, remember to:

  • Always disconnect the observer: don't forget to call this.observer.unobserve(element) and this.observer.disconnect on the Stimulus disconnect method. This will avoid potential memory leaks and further performance issues.
  • Perform throttling when possible: if we need to perform complex calculations or DOM manipulation as a result of the resize events, we could use a debounce function, so the actual operations are not performed every time the event is triggered.
  • Only observe what you need: even if we can observe multiple elements simultaneously, going overboard can deteriorate the performance of our website. Caution is advisable.

Debouncing is a technique used to limit how often a function can execute. It's especially useful when the changes that trigger the effect—the element resizing in our case—happen faster than the ability to react accordingly. Search forms are a good example of this technique: if we submit a request to the search endpoint before we finish typing the word, we would produce a bad result and unnecessary server requests. With debouncing, we make sure the request is produced a prudent time after we stopped typing.

Summary

The Resize Observer API is an efficient way to track changes to an element's size and apply actions based on those changes.

In the past, we had to use alternatives like attaching a resize event listener to the window object, which would fire any time the window changed in size, even if we were not interested in the window changes itself but on a specific node of the DOM.

With the Resize Observer API, we can listen for changes to any element in the document and react to those changes specifically, which keeps things contained and more efficient.

It has many potential use cases, especially those where we need to perform calculations when an element resizes and operate upon those calculations.

To make it work, we have to create a new instance of the ResizeObserver class which receives a callback that will have access to an array of instances ResizeObserverEntry that represent the elements that were resized:

const container = document.querySelector("#container")
const observer = new ResizeObserver((entries) => {
  entries.map((entry) => {
    // We perform operations with the resized entry
  })
})
observer.observe(container)

With the advent of container queries, some work previously achievable using the RO is now possible without it but there are many cases where it's still indispensable.

As usual, we should keep an eye on performance by disconnecting the observer after using it, debounce or throttle expensive calculations and only observe the items we need.

I hope you enjoyed the article and that you get to build amazing stuff with this wonderful API.

Have a good one and happy coding!

Build your next rails app 10x faster with Avo

Avo dashboard showcasing data visualizations through area charts, scatterplot, bar chart, pie charts, custom cards, and others.

Find out how Avo can help you build admin experiences with Rails faster, easier and better.