ResizeObserver: react when an element changes size

The window resize event only tells you about the window. ResizeObserver tells you when a specific element gets wider or taller, whatever the reason.

To detect when an element changes size in JavaScript, create a ResizeObserver and call observe() on the element.

The browser calls your function with the new size after layout and before paint, whatever caused the change: the window, a sidebar, a longer title or a CSS rule.

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { inlineSize, blockSize } = entry.contentBoxSize[0];
    console.log(entry.target, inlineSize, blockSize);
  }
});
ro.observe(document.querySelector('.panel'));

Try it. Drag the corner of the blue box (it uses CSS resize: both), or move the slider on a phone. The table shows what each entry reports.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ResizeObserver size readout</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: flex; gap: 8px; align-items: center; font-size: 14px; margin-bottom: 10px; }
  input[type=range] { flex: 1; max-width: 260px; }
  .box {
    width: 220px; height: 90px; min-width: 80px; min-height: 60px; max-width: calc(100% - 44px);
    padding: 16px; border: 6px solid #2563eb; border-radius: 10px; background: #fff;
    resize: both; overflow: auto;   /* resize only works when overflow is not visible */
    font-size: 13px; color: #5b6270;
  }
  table { margin-top: 12px; border-collapse: collapse; font-size: 14px; background: #fff; }
  td, th { padding: 6px 10px; border: 1px solid #dde1e7; text-align: left; }
  td:last-child { font: 600 14px ui-monospace, Consolas, monospace; }
  #count { font-size: 13px; color: #5b6270; margin-top: 8px; }
</style>
</head>
<body>
<label>Width <input type="range" id="w" min="80" max="320" value="220"></label>
<div class="box" id="box">Drag my bottom-right corner, or use the slider. Padding 16px, border 6px.</div>

<table>
  <tr><th>entry field</th><th>width x height</th></tr>
  <tr><td>contentRect</td><td id="cr"></td></tr>
  <tr><td>contentBoxSize[0]</td><td id="cb"></td></tr>
  <tr><td>borderBoxSize[0]</td><td id="bb"></td></tr>
</table>
<div id="count"></div>

<script>
  const box = document.getElementById('box');
  const r = (n) => Math.round(n);
  let calls = 0;

  const ro = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const c = entry.contentBoxSize[0];   // inlineSize = width, blockSize = height
      const b = entry.borderBoxSize[0];    // adds padding and border
      document.getElementById('cr').textContent = r(entry.contentRect.width) + ' x ' + r(entry.contentRect.height);
      document.getElementById('cb').textContent = r(c.inlineSize) + ' x ' + r(c.blockSize);
      document.getElementById('bb').textContent = r(b.inlineSize) + ' x ' + r(b.blockSize);
    }
    document.getElementById('count').textContent = 'Callback ran ' + (++calls) + ' times';
  });
  ro.observe(box);  // the first callback arrives right after this, with the current size

  document.getElementById('w').addEventListener('input', (e) => {
    box.style.width = e.target.value + 'px';
  });
</script>
</body>
</html>
One observer on one box. Each size change runs the callback once per frame, with three ways to read the size.

The callback also runs once right after observe(), with the current size. You do not need a separate "measure on load" step.

ResizeObserver vs the window resize event

The resize event on window only fires when the browser window changes. A chart inside a panel does not care about the window. It cares about the panel, and the panel can shrink while the window stays exactly the same.

A sidebar opens. The window resize event stays silent; an observer on the panel reports the new width.
A sidebar opens. The window resize event stays silent; an observer on the panel reports the new width.

You also cannot observe the window itself. observe(window) throws a TypeError, because the method only accepts elements. Keep the resize event for the window, and use an observer for anything inside the page.

What an entry contains

Each entry has a target (the element) and the size in three forms. contentBoxSize and borderBoxSize are arrays; read item [0]. inlineSize is the width and blockSize the height in normal horizontal text.

contentBoxSize is inside the padding. borderBoxSize is the outer edge. contentRect is the older rectangle form.
contentBoxSize is inside the padding. borderBoxSize is the outer edge. contentRect is the older rectangle form.
Field Measures Use it for
contentBoxSize[0] Inside the padding Space available for content, such as a canvas
borderBoxSize[0] Content + padding + border The size the element takes on screen
contentRect Content box as a rectangle Older code; same width and height as the content box
devicePixelContentBoxSize[0] Content box in device pixels Exact canvas buffer size, where the browser provides it

In the first demo the box is 220px wide with 16px padding and a 6px border, so the content width reads 220 and the border box reads 264.

The box option

The second argument to observe() picks which box triggers the callback:

ro.observe(el);                          // default: 'content-box'
ro.observe(el, { box: 'border-box' });
ro.observe(el, { box: 'device-pixel-content-box' });

The choice matters when only padding or border changes. With the default, changing padding alone does not call you, because the content box did not move. With border-box, the same padding change does. Every entry still carries all the sizes.

Element breakpoints, and when container queries are better

A card in a narrow sidebar and the same card in a wide main column need different layouts, even at the same window width. Media queries only see the viewport. ResizeObserver lets the card decide by its own width.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Element breakpoints: ResizeObserver vs container queries</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: flex; gap: 8px; align-items: center; font-size: 14px; margin-bottom: 4px; }
  input[type=range] { flex: 1; max-width: 280px; }
  h3 { font-size: 13px; margin: 14px 0 6px; color: #5b6270; font-weight: 600; }
  .slot { width: 420px; max-width: 100%; }
  .card {
    display: grid; gap: 12px; padding: 12px; border-radius: 12px;
    background: #fff; box-shadow: 0 3px 12px rgba(0, 0, 0, .1);
  }
  .pic { height: 70px; border-radius: 8px; background: linear-gradient(135deg, #60a5fa, #a78bfa); }
  .card b { display: block; margin-bottom: 4px; }
  .card p { margin: 0; font-size: 13px; color: #5b6270; }

  /* A: a class set by ResizeObserver */
  #js.wide { grid-template-columns: 110px 1fr; }
  #js.wide .pic { height: auto; min-height: 70px; }

  /* B: the same rule as a container query, no JavaScript */
  .cq { container-type: inline-size; }
  @container (min-width: 300px) {
    .card.css { grid-template-columns: 110px 1fr; }
    .card.css .pic { height: auto; min-height: 70px; }
  }
  .state { font: 600 12px ui-monospace, Consolas, monospace; color: #1d4ed8; }
</style>
</head>
<body>
<label>Slot width <input type="range" id="w" min="180" max="420" value="420"> <span id="wv"></span></label>

<h3>A · ResizeObserver adds a class <span class="state" id="st"></span></h3>
<div class="slot"><div class="card" id="js">
  <div class="pic"></div>
  <div><b>Weekly report</b><p>Side by side at 300px and wider, stacked below that.</p></div>
</div></div>

<h3>B · CSS container query, no script</h3>
<div class="slot cq"><div class="card css">
  <div class="pic"></div>
  <div><b>Weekly report</b><p>Side by side at 300px and wider, stacked below that.</p></div>
</div></div>

<script>
  const card = document.getElementById('js');

  // The card's own width picks the layout, not the window's width
  const ro = new ResizeObserver(([entry]) => {
    const width = entry.borderBoxSize[0].inlineSize;
    const wide = width >= 300;
    card.classList.toggle('wide', wide);
    document.getElementById('st').textContent = Math.round(width) + 'px, ' + (wide ? 'wide' : 'narrow');
  });
  ro.observe(card);

  // Resize both slots together
  const slider = document.getElementById('w');
  const label = document.getElementById('wv');
  slider.addEventListener('input', () => {
    document.querySelectorAll('.slot').forEach((s) => (s.style.width = slider.value + 'px'));
    label.textContent = slider.value + 'px';
  });
  label.textContent = slider.value + 'px';
</script>
</body>
</html>
Top: the observer toggles a "wide" class at 300px. Bottom: the same rule written as a container query, no script.
new ResizeObserver(([entry]) => {
  const width = entry.borderBoxSize[0].inlineSize;
  card.classList.toggle('wide', width >= 300);
}).observe(card);

Both halves switch at the same point. If all you change is styling, the CSS version is shorter and runs without JavaScript:

.slot { container-type: inline-size; }
@container (min-width: 300px) {
  .card { grid-template-columns: 110px 1fr; }
}

Reach for ResizeObserver when script needs the actual number: how many columns of data to render, how many tabs fit before a "More" menu, or what size to draw a canvas. For viewport-wide rules, media queries remain the simpler tool.

Redraw a canvas chart to fit its box

A canvas has two sizes: its CSS size on the page and its pixel buffer (canvas.width, canvas.height).

When the box grows and only the CSS size follows, the browser stretches the old buffer and the chart goes soft. Why a canvas looks blurry explains the mismatch in detail.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Canvas chart that fits its box</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  label { display: flex; gap: 8px; align-items: center; font-size: 14px; margin-bottom: 12px; }
  input[type=range] { flex: 1; max-width: 280px; }
  .chart {
    width: 100%; height: 260px;
    background: #fff; border-radius: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, .1);
  }
  /* The canvas fills the box. Its CSS size never depends on its pixel buffer. */
  canvas { display: block; width: 100%; height: 100%; }
  #info { font: 13px ui-monospace, Consolas, monospace; color: #5b6270; margin-top: 10px; }
</style>
</head>
<body>
<label>Chart width <input type="range" id="w" min="30" max="100" value="100"> <span id="wv">100%</span></label>
<div class="chart" id="chart"><canvas id="c"></canvas></div>
<div id="info"></div>

<script>
  const box = document.getElementById('chart');
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');
  const data = [12, 19, 8, 15, 22, 17, 25, 21, 28, 24, 31, 27];

  function draw(w, h) {
    const dpr = window.devicePixelRatio || 1;
    // Pixel buffer = CSS size x devicePixelRatio, so lines stay sharp
    canvas.width = Math.round(w * dpr);
    canvas.height = Math.round(h * dpr);
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);   // draw in CSS pixels from here on

    const pad = 28, max = Math.max(...data);
    const x = (i) => pad + (i * (w - pad * 2)) / (data.length - 1);
    const y = (v) => h - pad - (v / max) * (h - pad * 2);

    ctx.strokeStyle = '#e5e7eb'; ctx.lineWidth = 1;
    for (let g = 0; g <= 4; g++) {
      const gy = pad + (g * (h - pad * 2)) / 4;
      ctx.beginPath(); ctx.moveTo(pad, gy); ctx.lineTo(w - pad, gy); ctx.stroke();
    }
    ctx.strokeStyle = '#2563eb'; ctx.lineWidth = 2.5; ctx.lineJoin = 'round';
    ctx.beginPath();
    data.forEach((v, i) => (i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v))));
    ctx.stroke();
    ctx.fillStyle = '#2563eb';
    data.forEach((v, i) => { ctx.beginPath(); ctx.arc(x(i), y(v), 3.5, 0, Math.PI * 2); ctx.fill(); });
    ctx.fillStyle = '#5b6270'; ctx.font = '12px system-ui, sans-serif';
    ctx.fillText('Weekly sign-ups', pad, 18);

    document.getElementById('info').textContent =
      'box ' + Math.round(w) + ' x ' + Math.round(h) + ' CSS px, buffer ' +
      canvas.width + ' x ' + canvas.height + ', dpr ' + dpr;
  }

  // Observe the box, not the canvas, so setting the buffer size cannot loop back
  new ResizeObserver(([entry]) => {
    const { inlineSize, blockSize } = entry.contentBoxSize[0];
    draw(inlineSize, blockSize);
  }).observe(box);

  document.getElementById('w').addEventListener('input', (e) => {
    box.style.width = e.target.value + '%';
    document.getElementById('wv').textContent = e.target.value + '%';
  });
</script>
</body>
</html>
Move the slider. The observer redraws at the new size, with the buffer set to CSS size times devicePixelRatio.

The pattern in the demo:

  1. Give the canvas width: 100%; height: 100% inside a sized box, and observe the box, not the canvas.
  2. In the callback, set the buffer to the box size times devicePixelRatio.
  3. Call setTransform(dpr, 0, 0, dpr, 0, 0) and draw in CSS pixels.

Setting canvas.width clears the canvas, so the callback has to draw everything again. Keep the data outside the draw function so a redraw is cheap.

Sync a growing textarea with the rest of the page

An auto-growing textarea sets its own height on input, as shown in the HTML textarea guide. The user can also drag its corner. An observer catches both, so a line-number gutter or a preview panel can match its height.

new ResizeObserver(([entry]) => {
  gutter.style.height = entry.borderBoxSize[0].blockSize + 'px';
}).observe(textarea);

This changes a different element, so it cannot feed back into the textarea. Changing the textarea's own height here is what starts the loop error below.

The loop error and how to avoid it

"ResizeObserver loop completed with undelivered notifications" appears when a callback resizes an element that the browser has already handled in the current frame, most often the observed element itself.

The browser holds that notification for the next frame and fires an error event on window. Error overlays and error trackers pick it up.

Resizing the observed element inside its callback triggers the error. Deferring the change to the next frame does not.
Resizing the observed element inside its callback triggers the error. Deferring the change to the next frame does not.

Three ways out, in order of preference:

  • Change something else. Observe the container and resize a child, as the canvas demo does.
  • Only change when needed. Compare with the last value and return early if nothing would change.
  • Defer it. Wrap the change in requestAnimationFrame, so it lands in the next frame and is observed normally.
new ResizeObserver(([entry]) => {
  requestAnimationFrame(() => {
    const want = Math.round(entry.contentBoxSize[0].inlineSize / 2);
    if (el.offsetHeight !== want) el.style.height = want + 'px';
  });
}).observe(el);

The guard matters. Without it, a deferred change can still resize the element every frame forever, just without the error. requestAnimationFrame covers how frame timing works.

Stop observing: unobserve and disconnect

ro.unobserve(el) stops watching one element. ro.disconnect() stops all of them. Call one of them when the element or component goes away, so the callback does not keep running for something nobody sees.

In React, the observer lives in an effect and disconnects in the cleanup:

function useSize(ref) {
  const [size, setSize] = useState(null);
  useEffect(() => {
    const ro = new ResizeObserver(([entry]) => {
      const { inlineSize, blockSize } = entry.borderBoxSize[0];
      setSize({ width: inlineSize, height: blockSize });
    });
    ro.observe(ref.current);
    return () => ro.disconnect();
  }, [ref]);
  return size;
}

To react when an element scrolls into view rather than when it changes size, use IntersectionObserver instead. To react when its children or attributes change, use MutationObserver.

When it does not work

What you see Cause Fix
"ResizeObserver loop completed with undelivered notifications" The callback resizes the element it observes Resize a different element, add a guard, or defer with requestAnimationFrame
TypeError on observe(window) Only elements can be observed Use the resize event, or observe document.documentElement
Canvas blurry after the box grows Only the CSS size changed, not the buffer Set canvas.width and canvas.height to size times devicePixelRatio and redraw
Canvas keeps growing on its own The observer watches the canvas, and the canvas size follows its buffer Observe the wrapper; give the canvas width: 100% and height: 100%
First reading is 0 by 0 The element has display: none or no size yet Wait: the callback runs again when it gets a size
Size read on page load is wrong Measured before layout or fonts settled Read sizes in the callback, not once on load
Changing padding does nothing The default box is content-box Observe with { box: 'border-box' }
Resizing feels sluggish Heavy work runs in every callback Keep the callback to measuring and drawing; debounce expensive work with a timer

A resizing demo only makes sense when someone can drag the edge themselves. A screenshot is frozen at one width, and an .html file sent as an attachment may open as plain code on a phone.

Paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can resize the boxes and watch the chart redraw. If you change the code later, the same link shows the new version.

Questions people ask

What is the difference between ResizeObserver and the window resize event?

The resize event fires when the browser window changes size. ResizeObserver fires when an observed element changes size, which also happens when a sidebar opens, text gets longer, fonts load or a parent changes width while the window stays the same.

Can ResizeObserver observe the window?

No. observe() only accepts an element and throws a TypeError for window. Use the resize event for the window, or observe document.documentElement if you want the size of the page's root element.

What does "ResizeObserver loop completed with undelivered notifications" mean?

A callback changed the size of an element in a way that needed another notification in the same frame, so the browser held that notification until the next frame and reported an error event. Do not resize the observed element inside its own callback, or defer the change with requestAnimationFrame and skip it when the size is already right. Older versions of Chrome worded it as "ResizeObserver loop limit exceeded".

How do I use ResizeObserver in React?

Create the observer inside useEffect, observe the element from a ref, store the size in state, and return a cleanup function that calls disconnect(). That way the observer stops when the component unmounts.

Should I use ResizeObserver or CSS container queries?

If the only goal is to change styles at a width, a container query does it in CSS with no script. Use ResizeObserver when JavaScript needs the number: redrawing a canvas, choosing how many items to render, or syncing another element's size.

Keep reading