Making a tab interface with CSS is a never-ending topic in the world of modern web development. Are they possible? If yes, could they be accessible? I wrote how to build them the first time nine long years ago, and how to integrate accessible practices into them.
Although my solution then could possibly still be applied today, I’ve landed on a more modern approach to CSS tabs using the <details> element in combination with CSS Grid and Subgrid.
First, the HTML
Let’s start by setting up the HTML structure. We will need a set of <details> elements inside a parent wrapper that we’ll call .grid. Each <details> will be an .item as you might imagine each one being a tab in the interface.
<div class="grid">
<!-- First tab: set to open -->
<details class="item" name="alpha" open>
<summary class="subitem">First item</summary>
<div><!-- etc. --></div>
</details>
<details class="item" name="alpha">
<summary class="subitem">Second item</summary>
<div><!-- etc. --></div>
</details>
<details class="item" name="alpha">
<summary class="subitem">Third item</summary>
<div><!-- etc. --></div>
</details>
</div>

These don’t look like true tabs yet! But it’s the right structure we want before we get into CSS, where we’ll put CSS Grid and Subgrid to work.
Next, the CSS
Let’s set up the grid for our wrapper element using — you guessed it — CSS Grid. Basically what we’re making is a three-column grid, one column for each tab (or .item), with a bit of spacing between them.
We’ll also set up two rows in the .grid, one that’s sized to the content and one that maintains its proportion with the available space. The first row will hold our tabs and the second row is reserved for the displaying the active tab panel.
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(200px, 1fr));
grid-template-rows: auto 1fr;
column-gap: 1rem;
}
Now we’re looking a little more tab-like:

Next, we need to set up the subgrid for our tab elements. We want subgrid because it allows us to use the existing .grid lines without nesting an entirely new grid with new lines. Everything aligns nicely this way.
So, we’ll set each tab — the <details> elements — up as a grid and set their columns and rows to inherit the main .grid‘s lines with subgrid.
details {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
}
Additionally, we want each tab element to fill the entire .grid, so we set it up so that the <details> element takes up the entire available space horizontally and vertically using the grid-column and grid-row properties:
details {
display: grid;
grid-template-columns: subgrid;
grid-template-rows: subgrid;
grid-column: 1 / -1;
grid-row: 1 / span 3;
}
It looks a little wonky at first because the three tabs are stacked right on top of each other, but they cover the entire .grid which is exactly what we want.

Next, we will place the tab panel content in the second row of the subgrid and stretch it across all three columns. We’re using ::details-content (good support, but not yet in WebKit at the time of writing) to target the panel content, which is nice because that means we don’t need to set up another wrapper in the markup simply for that purpose.
details::details-content {
grid-row: 2; /* position in the second row */
grid-column: 1 / -1; /* cover all three columns */
padding: 1rem;
border-bottom: 2px solid dodgerblue;
}
The thing about a tabbed interface is that we only want to show one open tab panel at a time. Thankfully, we can select the [open] state of the <details> elements and hide the ::details-content of any tab that is :not([open])by using enabling selectors:
details:not([open])::details-content {
display: none;
}
We still have overlapping tabs, but the only tab panel we’re displaying is currently open, which cleans things up quite a bit:

Turning <details> into tabs
Now on to the fun stuff! Right now, all of our tabs are visually stacked. We want to spread those out and distribute them evenly along the .grid‘s top row. Each <details> element contains a <summary> providing both the tab label and button that toggles each one open and closed.
Let’s place the <summary> element in the first subgrid row and add apply light styling when a <details> tab is in an [open] state:
summary {
grid-row: 1; /* First subgrid row */
display: grid;
padding: 1rem; /* Some breathing room */
border-bottom: 2px solid dodgerblue;
cursor: pointer; /* Update the cursor when hovered */
}
/* Style the <summary> element when <details> is [open] */
details[open] summary {
font-weight: bold;
}
Our tabs are still stacked, but how we have some light styles applied when a tab is open:

We’re almost there! The last thing is to position the <summary> elements in the subgrid’s columns so they are no longer blocking each other. We’ll use the :nth-of-type pseudo to select each one individually by their order in the HTML:
/* First item in first column */
details:nth-of-type(1) summary {
grid-column: 1 / span 1;
}
/* Second item in second column */
details:nth-of-type(2) summary {
grid-column: 2 / span 1;
}
/* Third item in third column */
details:nth-of-type(3) summary {
grid-column: 3 / span 1;
}
Check that out! The tabs are evenly distributed along the subgrid’s top row:

Unfortunately, we can’t use loops in CSS (yet!), but we can use variables to keep our styles DRY:
summary {
grid-column: var(--n) / span 1;
}
Now we need to set the --n variable for each <details> element. I like to inline the variables directly in HTML and use them as hooks for styling:
<div class="grid">
<details class="item" name="alpha" open style="--n: 1">
<summary class="subitem">First item</summary>
<div><!-- etc. --></div>
</details>
<details class="item" name="alpha" style="--n: 2">
<summary class="subitem">Second item</summary>
<div><!-- etc. --></div>
</details>
<details class="item" name="alpha" style="--n: 3">
<summary class="subitem">Third item</summary>
<div><!-- etc. --></div>
</details>
</div>
Again, because loops aren’t a thing in CSS at the moment, I tend to reach for a templating language, specifically Liquid, to get some looping action. This way, there’s no need to explicitly write the HTML for each tab:
{% for item in itemList %}
<div class="grid">
<details class="item" name="alpha" style="--n: {{ forloop.index }}" {% if forloop.first %}open{% endif %}>
<!-- etc. -->
</details>
</div>
{% endfor %}
You can roll with a different templating language, of course. There are plenty out there if you like keeping things concise!
Final touches
OK, I lied. There’s one more thing we ought to do. Right now, you can click only on the last <summary> element because all of the <details> pieces are stacked on top of each other in a way where the last one is on top of the stack.
You might have already guessed it: we need to put our <summary> elements on top by setting z-index.
summary {
z-index: 1;
}
Here’s the full working demo:
Accessibility
The <details> element includes built-in accessibility features, such as keyboard navigation and screen reader support, for both expanded and collapsed states. I’m sure we could make it even better, but it might be a topic for another article. I’d love some feedback in the comments to help cover as many bases as possible.
Update: Nathan Knowler chimed in with some excellent points over on Mastodon. Adrian Roselli buzzed in with additional context in the comments as well.
It’s 2025, and we can create tabs with HTML and CSS only without any hacks. I don’t know about you, but this developer is happy today, even if we still need a little patience for browsers to fully support these features.
Hi,
The number of tabs can also be dynamic : https://codepen.io/jpyrat/pen/NPxzmWd
That’s cool!
Loathe as I am to suggest looking at APG, its tabs patterns are pretty good. I encourage you to try them at least using the screen reader built into your device and then again with your own demo. If you are unfamiliar with using a screen reader, there are plenty of resources online, but working with blind users is ideal.
Looking at the tab pattern that is activated explicitly (the user has to manually activate the tab, similar to your approach, versus just focusing it to activate it), note the differences.
The APG pattern:
conveys how many tabs are in the set;
conveys on which number of the set the user is focused;
allows arrow keys to navigate tabs (because it’s a widget);
supports the Home and End keys;
uses roles (
tabandtablist) that allows the screen reader to tell the user how to operate the tabs;a user can never hide all tabs at once.
Your approach to hide the content completely (
display: none;) when not open (details:not([open])::details-content) means the browser’s find-in-page feature will never activate a different tab for a hit, so that’s a neat trick.Broadly, a “CSS-only” or “pure CSS” widget won’t have the same accessibility features as a built-for-purpose pattern.
I appreciate that you explicitly asked for accessibility feedback in the article. I hope mine is useful to you.
I applaud your choice of sample data.
Brilliant usage of subgrid! I nested the CSS and ported it to Svelte to use {#each} for templating with data.
https://svelte.dev/playground/5a25ea7854e04b76bb0d6c9b9ff02ba3?version=5.42.3
Looking forward for more feedback on accessibility.
Would it be simpler to use
display: contents;on thedetailsrather than using subgrid? Or would thedetails::details-contentnot work as expected in that case?Broadly, don’t rely on
display: contents. Browsers (and the spec) have done it dirty for years. For this specific case, there is a recent Safari bug wheredisplay: contentsbreaks<details>. And yes, I am angry I still have to track browser regressions ondisplay: contents.That does work, however, there is a fairly bad accessibility related bug in Safari when you do that: https://bugs.webkit.org/show_bug.cgi?id=299321
Indeed, works smoothly on Chrome and Firefox. It’s much simpler, avoiding the z-index issues that persist in the current version (Details > Div child).
I believe
:not(:open)would also workJust for fun, use
sibling-index()andsibling-count()