Removing wrapper nodes from CSS Grid and Flexbox with display: contents
The display: contents property allows an element to visually disappear while its children participate directly in the parent's Grid or Flexbox layout. This article breaks down how it works, when to use it for semantic HTML, and the historical accessibility bugs you need to know.

Removing wrapper nodes from CSS Grid and Flexbox with display: contents
display: contents makes an element's own box disappear from rendering while its children stay in the DOM and become direct participants in the parent's layout context. A <li> set to display: contents inside a grid stops being a grid item itself; its children take that role instead. The result is markup that keeps its semantics and a layout tree that stays flat.
This solves a specific tension. Grid and Flexbox both expect their items to be direct children of the container, but semantic HTML frequently forces a wrapper (<li>, <figure>, a component's root <div>) between the container and the content you actually want to lay out. Before this property, you either dropped the semantic element to get clean layout, or kept it and fought nested box models. Now you get both, with some accessibility history worth knowing before you rely on it.
How does display: contents change the rendering tree?
Setting display: contents on an element removes its box entirely: no content box, no padding, no border, no background, no box-shadow. The element remains in the DOM and, as of current browser versions, in the accessibility tree. Visually, it behaves as if you deleted the tag and left its children sitting exactly where it was.
For Grid and Flexbox this matters because the spec defines grid items and flex items as the direct children of a display: grid or display: flex container. Wrap those children in a semantic element and that wrapper becomes the item, laying out its own children in a plain block context underneath. Set display: contents on the wrapper and it drops out of the box tree, promoting its children to be the real grid or flex items.
Here's the classic case: a semantic list feeding a card grid.
1<ul class="card-grid">2 <li class="card-wrapper">3 <article class="card">4 <h3>Alpine Trail Map</h3>5 <p>Contour lines for the northern ridge route.</p>6 </article>7 </li>8 <li class="card-wrapper">9 <article class="card">10 <h3>Coastal Path Guide</h3>11 <p>Tide tables and shelter points along the bay.</p>12 </article>13 </li>14 <li class="card-wrapper">15 <article class="card">16 <h3>Forest Loop Notes</h3>17 <p>Elevation gain and water crossing warnings.</p>18 </article>19 </li>20</ul>1.card-grid {2 display: grid;3 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));4 gap: 1.5rem;5 /* Strip default list styling so it doesn't leak into the grid */6 list-style: none;7 padding: 0;8 margin: 0;9}1011/* The <li> box disappears completely.12 Its child .card becomes the actual grid item. */13.card-wrapper {14 display: contents;15}1617.card {18 background: #fff;19 border: 1px solid #ddd;20 border-radius: 8px;21 padding: 1rem;22 /* This border and padding now render on the real grid item,23 not on an invisible <li> wrapping it. */24}Without display: contents on .card-wrapper, the <li> elements are the grid items, and .card is just a block box stretching to fill its parent. That's fine in plenty of layouts. It stops being fine the moment you need .card to participate in subgrid alignment, or you want gap and align-items to apply against the card's actual visual edge instead of an invisible list-item box sitting around it.
When should you reach for display: contents instead of restructuring your HTML?
Only when semantics require a wrapper that layout doesn't. The recurring cases I actually use this for:
- A <ul>/<li> list of cards or nav items where the items need to be true grid or flex children.
- A <figure> wrapping an <img> and <figcaption> where the image should be a grid item and the caption should land in a separate grid area, rather than stacking inside the figure's own block flow.
- Framework component output. React, Vue, and Angular all like to emit a wrapper element around a component's rendered markup. That wrapper breaks a flex row's gap calculation and messes with align-items unless you either restructure the component or set display: contents on the root node.
If there's no semantic reason for the wrapper, delete it instead of hiding it with CSS. display: contents fixes a structural conflict between markup semantics and layout mechanics. It is not a general "flatten this div" tool, and using it that way just adds an invisible node for no reason.
Don't reach for it when the wrapper is doing visual work: backgrounds, borders, shadows, padding used as a larger click target, transitions on box properties. All of that disappears along with the box. This is the single most common mistake I see: someone applies display: contents to simplify a grid, then can't figure out why a background color or hover shadow silently stopped rendering on that element.
What accessibility bugs have been associated with display: contents?
This is the part that earned the property a bad reputation for a while, and it's worth knowing the specifics rather than a vague "it used to have bugs."
The core historical issue: Chrome's Blink engine, in versions before the fix landed around Chrome 82 in 2020, removed elements set to display: contents from the accessibility tree, and in some cases stripped their accessible descendants too. Visually the content was there. To a screen reader, it could be gone, or its accessible name and role could go missing. This hit form labels, links, and interactive elements nested inside layout wrappers that had been set to display: contents purely for grid or flex purposes, which is exactly the pattern this article recommends.
Firefox had a related problem: ARIA attributes and roles applied directly to an element with display: contents were dropped from the accessibility tree in earlier implementations, because the initial interpretation treated "no box" as "no accessibility node" too.
The CSS Display specification was clarified to state explicitly that removing an element's box must not remove its accessible name, role, or ARIA semantics. Browser vendors aligned with that clarification, and current stable releases of Chrome, Firefox, and Safari expose display: contents elements and their descendants correctly, including elements carrying explicit ARIA roles. You can check the current specification language and browser notes directly on MDN's display property page, and confirm version-level support on caniuse.com's display: contents entry, which tracks the specific browser versions where the accessibility tree fix shipped.
Two things I'd still test by hand rather than assume are fine:
- Table structures. Applying display: contents to <tr>, <td>, or elements inside <table> still produces inconsistent results across browsers, because table layout generates its own anonymous boxes and the interaction with contents removal isn't fully settled. Skip it here; restructure the markup instead.
- Click and focus target size. This isn't an accessibility tree bug, it's a hit-area regression. If a wrapper's padding was quietly enlarging a tap target, removing that box removes the enlarged target too. That hits the same population, motor-impaired users and anyone on a touch device with imprecise input, that WCAG's target size criterion is meant to protect. Move the padding onto the actual interactive child element before you ship.
My practical stance: display: contents is production-ready for layout purposes in any project targeting evergreen browsers today. For a plain <li> or <figure> wrapper with no special ARIA behavior, ship it without hesitation. If the wrapper you're removing carries an ARIA role, a live region, or custom labeling, run one real pass with VoiceOver on Safari and NVDA on Firefox or Chrome before merging. The fix is solid for standard structural cases; exotic ARIA patterns are rare enough that I still verify them manually rather than trust the spec language blindly.
Does the Flexbox version behave the same way, and what happens to gap?
Same mechanism, different container. Here's a nav bar where <li> wrappers are semantically correct but shouldn't be flex items themselves:
1<nav aria-label="Primary">2 <ul class="nav-list">3 <li class="nav-item"><a href="/docs">Docs</a></li>4 <li class="nav-item"><a href="/pricing">Pricing</a></li>5 <li class="nav-item"><a href="/blog">Blog</a></li>6 </ul>7</nav>1.nav-list {2 display: flex;3 gap: 1rem;4 list-style: none;5 padding: 0;6 margin: 0;7}89/* Removes the <li> box so the <a> becomes the flex item directly.10 Padding, hover states, and focus rings now apply to the <a>. */11.nav-item {12 display: contents;13}1415.nav-item a {16 display: inline-block;17 padding: 0.5rem 0.75rem;18 border-radius: 4px;19 text-decoration: none;20 color: #1a1a1a;21}2223.nav-item a: hover,24.nav-item a: focus-visible {25 background: #eef2ff;26}gap applies exactly where you'd want it: between the anchors, since they're now the real flex items, not between invisible <li> boxes. This is probably the single most common reason people reach for display: contents in a nav bar. Without it, you're either duplicating spacing with margins on the anchors or fighting the <li> boxes for gap behavior.
One gotcha specific to Flexbox: flex-grow, flex-shrink, and flex-basis set on the wrapper are simply ignored once it has display: contents, because there's no box left for those properties to apply to. If you're retrofitting this onto existing CSS, any flex sizing rules on the old wrapper need to move onto the actual child. It's an easy step to miss and the failure mode is subtle: items just stop sizing the way you expect, with no console warning to point you at the cause.
Key Takeaways
- display: contents removes an element's entire rendered box (background, border, padding, box-shadow) while its children become direct grid or flex items of the parent container.
- The Chrome accessibility tree bug that stripped display: contents elements and their descendants was fixed around Chrome 82 in 2020; current stable Chrome, Firefox, and Safari all expose these elements correctly, confirmed via caniuse.com.
- Still run a manual VoiceOver or NVDA check if the wrapper you're removing carries an ARIA role or live region. Plain structural wrappers like <li> and <figure> are safe without extra testing.
- Avoid display: contents on <tr> or <td> elements inside <table>, where anonymous box generation still produces inconsistent cross-browser output.
- flex-grow, flex-shrink, and flex-basis on a display: contents wrapper are silently ignored: move those properties onto the actual child element during any retrofit.
Related Articles

Serving responsive background images with the CSS image-set function
While the HTML <picture> element handles responsive inline images, CSS background images have historically required complex media queries. The image-set() function allows the browser to automatically select the most appropriate background image based on screen resolution and format support.

How to use CSS math functions round, mod, and rem for layout calculations
CSS now supports advanced mathematical functions like round(), mod(), and rem() natively. This article explains how to use these functions to calculate dynamic grid tracks, snap typography to a baseline grid, and avoid complex JavaScript resize listeners.