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.

display:none prunes an entire subtree from the render tree The DOM keeps every node, but the render tree drops the display:none element and all of its descendants so no boxes are created. DOM tree (all nodes kept) Render tree (pruned) body header section none row row body header subtree absent prune

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.

Pipeline phases touched by display:none versus visibility:hidden A comparison matrix showing which rendering phases each property touches: display:none skips the box entirely, visibility:hidden keeps layout but skips paint. Phase display: none visibility: hidden In render tree (has a box) Occupies layout space Emits paint commands Toggle cost no no no layout + paint yes yes no paint only

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.

Per-toggle pipeline cost by hiding property A grid comparing display:none, visibility:hidden and content-visibility across box reconstruction, layout, paint and composite; display:none pays every stage while visibility:hidden is paint-only. property rebuild boxes layout paint composite display: none runs runs runs runs visibility: hidden skipped skipped runs runs content-visibility skipped skipped skipped deferred off-screen subtree β€” stages run only when it scrolls into view

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.