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.
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
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:
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:
.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:
// 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.
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
getBoundingClientRectnear the tab handler. - Try
content-visibility: hiddenif a pre-measurement is genuinely needed, and confirm entries continue to arrive.
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.
Related
- ResizeObserver Mechanics & Triggers — the full list of what does and does not deliver an entry
- Observer Callback Not Called on Initial Render — the other case where a zero-dimension element looks like a broken observer
- Resizing a Canvas with ResizeObserver Without Blurring — why a zero backing store is unusable
↑ Back to ResizeObserver Mechanics & Triggers