Why display:none Elements Skip the Render Tree
When you toggle display: none on a subtree, the browser destroys and later rebuilds every box under it during render tree generation, and the show/hide transition triggers a full layout pass instead of a cheap repaint. This guide is part of Render Tree Generation, within Browser Rendering Pipeline Fundamentals, and it focuses on one decision engineers make dozens of times a day without measuring the cost: display: none versus visibility: hidden. The two look identical on screen β nothing is visible either way β but they land in completely different pipeline phases. One prunes the node from the render tree so it has no box, no geometry, and no paint record; the other keeps a fully laid-out box that simply renders no pixels.
The distinction matters because the render tree is the data structure that layout and paint walk. A node that is not in the render tree costs nothing during those phases, but the moment you add it back you pay to construct its boxes and re-run layout. A node that stays in the render tree already has its geometry; flipping its visibility only re-issues paint. If you pick the wrong one for a frequently toggled element, you convert a sub-millisecond repaint into a multi-millisecond reflow that blows the 16.6ms frame budget.
Minimal Reproduction: A Toggle That Reflows the Whole Panel
The snippet below toggles a details panel that holds a few hundred rows. It uses display: none to hide it, which means every open/close destroys and rebuilds the panelβs boxes. Watch the marked line.
<button id="toggle">Show details</button>
<section id="panel" class="hidden">
<!-- 400 rows injected at load; kept in the DOM the whole time -->
</section>
<style>
.hidden { display: none; } /* BAD: removes the box from the render tree entirely */
</style>
<script>
const panel = document.getElementById('panel')
const rows = Array.from({ length: 400 }, (_, i) => `<div class="row">Row ${i}</div>`)
panel.innerHTML = rows.join('')
document.getElementById('toggle').addEventListener('click', () => {
panel.classList.toggle('hidden')
// Removing `.hidden` re-inserts 400 boxes into the render tree,
// forcing a full layout of the subtree before the frame can paint.
})
</script>
Each click that reveals the panel walks all 400 rows, creates a LayoutObject for every one, computes their geometry from scratch, and only then paints. Each click that hides it destroys those same boxes. In a Performance trace the reveal frame shows a Layout event proportional to the row count β the exact reflow behaviour catalogued under reflow and repaint triggers β where a visibility toggle would have shown only Paint.
How the Render Tree Prunes a display:none Subtree
During render tree generation the main thread walks the DOM in document order and, for each element, consults its computed display value from the cascade resolved in Style Calculation and Cascade. When display computes to none, Blink stops descending: it creates no LayoutObject for that element and β critically β none for any descendant, regardless of their own display values. The subtree is absent from the render tree, so the layout and paint phases never see it. The DOM nodes still exist and are fully scriptable, but they hold no box, no computed geometry, and no layer. This is why offsetWidth, getBoundingClientRect(), and offsetParent all return zero or null for a display: none element β there is no render object to measure.
Because pruning happens per subtree, a single display: none on a container is cheap to keep hidden β the engine never pays to lay out its 400 rows while it stays hidden. The cost is entirely at the transition. Re-inserting the subtree means the style engine marks it dirty, builds a LayoutObject for each descendant, and the layout phase computes geometry for all of them in one synchronous pass before the frame can paint. That construction work is the same kind of main-thread expense you avoid with content-visibility and rendering subtrees, which defers it until the element scrolls near the viewport.
visibility:hidden Keeps the Box and Skips Only the Paint
visibility: hidden behaves entirely differently. The element stays in the render tree with a fully computed LayoutObject. It participates in layout, occupies its normal space, and pushes siblings around exactly as if it were visible β the only thing the browser withholds is the paint. During the paint phase the engine sees the visibility: hidden flag on the render object and emits no draw commands for it (though visible descendants that set visibility: visible still paint). Toggling visibility therefore never rebuilds boxes and never triggers layout, because the geometry already exists and does not change. It only dirties the paint record, so the browser re-runs paint for the affected region and composites β a far cheaper operation than reflow.
The trade-off is space. Because a visibility: hidden box still occupies layout, the hidden element leaves a gap the size of its geometry, and its cost during initial layout is identical to a visible element β the engine computes geometry for all 400 rows whether you can see them or not. So visibility: hidden is the right tool when the element toggles often and must not shift surrounding content, but it is the wrong tool for something you want to cost nothing while hidden. This maps directly onto the render-tree membership question covered in render tree vs DOM tree differences explained: display: none leaves the render tree, visibility: hidden does not.
| Property | Render tree | Occupies space | Show/hide cost |
|---|---|---|---|
display: none |
Absent | No | Box construction + layout + paint |
visibility: hidden |
Present | Yes | Paint only |
opacity: 0 |
Present | Yes | Paint (compositable if on its own layer) |
content-visibility: hidden |
Present, layout skipped | Yes (intrinsic size) | Deferred layout on reveal |
Reading the Toggle in a DevTools Trace
Capture a Performance trace with CPU throttling at 4x, click the toggle, and expand the frame that revealed the panel. The two properties leave signatures that are impossible to confuse. The display: none reveal shows a Recalculate Style marking the subtree dirty, a Layout whose duration scales with the row count, and then Paint. The visibility toggle shows Recalculate Style and Paint with no Layout between them.
[Main Thread] reveal frame β display:none β block Budget: 16.6ms
ββ 0.0ms - 0.4ms | EventDispatch: click
ββ 0.4ms - 1.1ms | Recalculate Style (0.7ms) β 400 rows marked dirty
ββ 1.1ms - 9.8ms | Layout (8.7ms) βββ box construction for 400 new LayoutObjects
β ββ subtree relayout: 400 rows, geometry from scratch
ββ 9.8ms - 12.6ms | Paint (2.8ms)
ββ 12.6ms - 13.1ms | Composite Layers (0.5ms) total β 12.6ms β near budget
[Main Thread] reveal frame β visibility:hidden β visible Budget: 16.6ms
ββ 0.0ms - 0.4ms | EventDispatch: click
ββ 0.4ms - 0.7ms | Recalculate Style (0.3ms) β visibility flag flipped
ββ 0.7ms - 0.7ms | Layout (0ms) βββ no relayout; geometry already computed
ββ 0.7ms - 2.9ms | Paint (2.2ms)
ββ 2.9ms - 3.3ms | Composite Layers (0.4ms) total β 3.3ms β well under budget
The 8.7ms Layout event in the first trace is the entire penalty of display: none on a hot toggle. If any script reads geometry (offsetHeight, getBoundingClientRect()) in the same task after flipping the class, that Layout is pulled forward as a forced synchronous layout, stalling the task even harder. The visibility trace has a zero-duration Layout because nothing about the box geometry changed β only the paint record was invalidated.
The Fix: Match the Property to the Toggle Frequency
Pick the property from the toggleβs behaviour, not habit. For an element that flips often and must hold its place β an inline validation message, a hover-revealed control, a spinner overlay β use visibility: hidden (or opacity if you want it compositable) so each toggle is paint-only. For something genuinely absent from the layout when hidden and toggled rarely, display: none is correct, but reveal it inside a requestAnimationFrame and never read its geometry in the same task. For large lists that must not pay layout while off-screen, reach for content-visibility and CSS containment instead.
/* BEFORE β a frequently toggled panel that must keep its place */
.panel { display: none; } /* rebuilds 400 boxes + full relayout every reveal */
.panel.open { display: block; }
/* AFTER β box stays in the render tree; each toggle is paint-only */
.panel {
visibility: hidden; /* keeps geometry, skips paint β no relayout on toggle */
}
.panel.open {
visibility: visible;
}
/* If the panel must NOT reserve space while hidden and toggles rarely,
keep display:none but reveal off the layout-read path: */
.lazy-panel { display: none; }
.lazy-panel.open { display: block; }
// AFTER β reveal a display:none subtree without a forced synchronous layout
function openPanel(panel) {
panel.classList.add('open')
requestAnimationFrame(() => {
// Read geometry only on the NEXT frame, after layout has flushed once,
// so the reveal's Layout is not pulled forward into this task.
const h = panel.offsetHeight
panel.style.setProperty('--measured-height', `${h}px`)
})
}
With visibility: hidden the reveal never constructs boxes, so the browser runs paint against geometry it already holds. With the display: none path the deferred read keeps the mandatory Layout from becoming a synchronous stall inside the click handler β the difference between one flush at the frame boundary and two flushes mid-task.
The table below makes the trade-off concrete: it maps each property choice to the pipeline stages a single toggle actually pays for. The property that flips most often should be the one that lights up the fewest cells.
Verification Checklist
Frequently Asked Questions
Does display:none remove the element from the DOM?
No. display: none only removes the element from the render tree, not the DOM. The node stays fully present and scriptable β you can read its attributes, set its innerHTML, and attach listeners β but it has no box, so geometry APIs like offsetWidth return zero and offsetParent returns null. To remove it from the DOM you must detach it with removeChild() or conditional rendering.
Is visibility:hidden faster to toggle than display:none?
For a frequently toggled element that keeps its place, yes. visibility: hidden leaves the box in the render tree with computed geometry, so flipping it only invalidates paint β no relayout. display: none destroys and rebuilds the subtreeβs boxes on every reveal, forcing a Layout whose cost scales with the number of descendants. The trade-off is that a visibility: hidden element still occupies layout space.
Do children of a display:none element still get styled?
The cascade still resolves computed style for the subtree so the engine can confirm the display: none applies, but no LayoutObject is created for any descendant and no layout or paint work is done for them. That is why keeping a large subtree hidden with display: none is cheap during steady state β the expense is entirely at the reveal, when boxes are constructed.
What about opacity:0 β is it the same as visibility:hidden?
Both keep the box in the render tree and both reserve layout space, so neither triggers a relayout on toggle. The difference is compositing: an element with opacity transitions can be promoted to its own layer and animated on the compositor thread, whereas visibility is a paint-phase flag. opacity: 0 also still receives pointer events unless you add pointer-events: none, while visibility: hidden blocks them by default.
Why does reading offsetHeight after showing a panel cause a stall?
Flipping display to a visible value dirties layout. If you then read a geometry property in the same task, the browser must resolve that pending layout synchronously before it can return an accurate value β a forced synchronous layout. Deferring the read to the next requestAnimationFrame lets layout flush once at the frame boundary instead of mid-task.
Related Guides
- Render Tree Generation β how the DOM and CSSOM merge into the tree that layout and paint walk.
- Render tree vs DOM tree differences explained β which nodes are pruned and why the gap costs style time.
- Reflow and repaint triggers β the full catalogue of property changes that force layout versus paint.
- Content-visibility and rendering subtrees β defer layout for off-screen subtrees without unmounting them.
- Forced synchronous layouts β why reading geometry after a mutation stalls the task.