A ResizeObserver watching an element inside a hidden tab panel appears to stop working. It has not stopped; it is reporting exactly what the specification says it should, and the code on the other side is not prepared for the answer.

Problem / Scenario Context

A tabbed settings page renders three panels, hiding the inactive ones with display: none. Each panel contains a chart that resizes with its container. Switching to a tab shows a chart drawn at the wrong size, or not drawn at all, until the window is nudged.

Instrumenting the callback shows what happens. On first observation of a hidden panel, one entry arrives with contentRect of 0 × 0. Nothing arrives after that, however much the layout around it changes. When the tab is activated, an entry finally arrives with the real size — but by then the chart has already been configured against zero, and several libraries treat a zero-size configuration as permanent.

Mechanics Explanation

display: none removes an element from the layout tree entirely. It has no box, so there is nothing to observe. The specification handles this by reporting a single observation of zero, which is the honest answer, and then delivering nothing further because nothing further changes — an element with no box cannot change size.

Two consequences follow, and both are the opposite of what people expect.

Hiding an element fires a callback. Setting display: none on an observed element produces one entry of 0 × 0. Code that treats every entry as a real measurement will happily configure itself for a zero-size world.

Showing it again fires another. The transition back produces an entry with the real size. So the observer is not broken and does not need re-observing; the callback simply has to distinguish the two cases.

visibility: hidden behaves completely differently: the element keeps its box, so the observer keeps reporting real sizes throughout. This is often the better choice for a panel whose contents need to stay measurable.

What Arrives Across a Hide/Show CycleTwo timelines through the same sequence of hiding a panel, resizing the window twice while it is hidden, and showing it again. With display none the observer delivers a zero entry on hide, nothing during the resizes, and a real entry on show. With visibility hidden the observer delivers real sizes throughout, including during the window resizes.display: nonehide0 × 0window resizewindow resize— silence —show640 × 360visibility: hidden640×360720×360880×360880×360measurable throughout — the box never went away

Comparison Table: Ways to Hide a Panel

Technique Box exists Observer reports Cost
display: none no one 0 × 0, then silence zero layout, zero paint
visibility: hidden yes real sizes throughout full layout, no paint
hidden attribute no same as display: none zero layout
content-visibility: hidden box only size of the box, not the contents layout of the box only
Off-screen positioning yes real sizes full layout and paint
Unmounting the component no element nothing — target is gone zero, but state is lost

content-visibility: hidden is the interesting middle ground for a tabbed interface: the panel keeps a box that can be measured, while its subtree is skipped entirely for layout and paint.

Minimal Reproducible Example

TypeScript
const chart = new Chart(panel);
new ResizeObserver(([entry]) => {
  chart.setSize(entry.contentRect.width, entry.contentRect.height);
}).observe(panel);

panel.style.display = 'none';    // → one entry of 0 × 0 → chart.setSize(0, 0)
// Some libraries never recover from a zero-size configuration.

Production-Safe Solution

Treat zero as "no information", not as a measurement:

TypeScript
const lastGood = new WeakMap<Element, { w: number; h: number }>();

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const box = entry.contentBoxSize?.[0];
    const w = box ? box.inlineSize : entry.contentRect.width;
    const h = box ? box.blockSize : entry.contentRect.height;

    if (w === 0 || h === 0) continue;            // hidden — keep the last real size

    const prev = lastGood.get(entry.target);
    if (prev && prev.w === w && prev.h === h) continue;   // no actual change
    lastGood.set(entry.target, { w, h });
    apply(entry.target, w, h);
  }
});

Skipping rather than clamping matters: clamping to a minimum would call apply with a fabricated size, which is the same mistake in a different costume.

If the panel must be measured while hidden — for example to lay out a chart before the tab is opened, so it appears complete rather than resizing in front of the reader — switch the hiding mechanism rather than fighting the observer:

CSS
.panel[hidden-visually] {
  content-visibility: hidden;   /* box remains, subtree skipped */
  contain: strict;
}

And if the element genuinely is display-hidden, the correct time to act is the transition back. Because that transition delivers an entry, no extra listener is needed — the skip above simply means the first entry the code acts on is the real one:

TypeScript
// Activating a tab produces a real entry, which flows through the same path.
tabButton.addEventListener('click', () => {
  panel.style.display = '';
  // no manual measure() call — the observer will deliver the size in the next frame
});

Removing that manual measure() call is worth calling out, because it is a very common addition that introduces a forced synchronous layout to obtain a number the observer is about to hand over for free.

Skip the Zero Entry, Do Not Clamp ItTwo handling strategies for a zero-sized entry. Clamping to a minimum passes a fabricated size to the chart, which then lays out for a size that does not exist and must be corrected later. Skipping retains the last real measurement, so the chart is untouched while hidden and receives a genuine size the moment the panel is shown.clamp to a minimumapply(el, Math.max(w, 1), …)the chart lays out for a size thatdoes not exist anywhereand must be corrected on showskip the entryif (w === 0 || h === 0) continuethe chart keeps its last real sizeand is not touched while hiddenthe show transition delivers the truthZero is not a small size. It is the absence of a size, and the only correct response is to wait for a real one.

Verification Steps

  • Log every entry with its dimensions while toggling a tab; you should see exactly one zero on hide and one real size on show.
  • Confirm the chart is untouched while hidden by checking its configured size does not change during the hidden period.
  • Confirm no manual measurement was added on the show path — search for getBoundingClientRect near the tab handler.
  • Try content-visibility: hidden if a pre-measurement is genuinely needed, and confirm entries continue to arrive.

Hiding Techniques and What SurvivesFour ways to hide a panel compared by whether the element keeps a layout box and what the observer reports. display none and the hidden attribute remove the box entirely. visibility hidden keeps everything but paint. content-visibility hidden keeps the panel’s own box while skipping its subtree, which is usually what a tabbed interface wants.Techniquebox?observer reports?display: nonenoone 0×0, then silence[hidden]nosame as display: nonevisibility: hiddenyesreal sizes throughoutcontent-visibility: hiddenyesthe panel’s own box; subtree skippedThe last row is usually what a tabbed interface actually wants.

Common Mistakes to Avoid

  • Treating zero as a measurement. It is the absence of one.
  • Re-observing on show. The observer never stopped; a fresh observe() merely adds a redundant initial entry.
  • Clamping to a minimum. Fabricates a size that nothing in the layout agrees with.
  • Measuring manually on tab activation. Forces layout to obtain a number that arrives on its own a frame later.

FAQ

Why does hiding an element fire a callback at all?

Because its box genuinely changed — from a real size to no size. The specification reports that as a single observation of zero by zero, which is accurate. What surprises people is that this is the last entry until the element is shown again, since an element with no box has nothing further to report.

Should I re-observe the element when the panel is shown?

No. The observer never stopped watching, and the transition back into layout produces an entry on its own. Calling observe again only adds a redundant initial delivery and, in code that treats the first entry specially, can cause the show path to run twice.

What is the difference between display none and visibility hidden here?

display none removes the element from the layout tree, so there is no box and nothing to measure. visibility hidden keeps the box and only suppresses painting, so the observer continues reporting real sizes throughout — which is often what a tabbed interface actually wants.

Is content-visibility a better option for tab panels?

Often, yes. content-visibility hidden keeps the panel's own box measurable while skipping layout and paint for its subtree, which gives most of the performance benefit of display none without the zero-measurement problem for anything observing the panel itself.


↑ Back to ResizeObserver Mechanics & Triggers