An ongoing series on techniques that ship with the browser already - no library, no build step required.
Tabs feel like a thing that needs JavaScript. There’s a selected tab to track, clicks to handle, and the right keyboard behavior to wire up. So most tab components ship a little bundle of script to manage all that.
For the common case - a few panels of content, one shown at a time - you don’t need any of it. A group of radio buttons already does exactly this: only one can be picked at a time, and the browser remembers which. Point some CSS at the checked one and you have tabs. Here’s a set, working with no JavaScript at all:
We start in grayscale and figure out the layout before any color goes near it. Structure first, polish second.
Hand-written HTML and CSS, no page builder. The markup stays clean enough that the next person can read it.
Static output on a CDN, so the page just appears. Then we hand you the keys and the docs to go with them.
HTML
<div class="tabs-demo">
<div class="tabs-row" role="tablist">
<input type="radio" name="tabs-demo" id="tab-design" checked />
<label for="tab-design">Design</label>
<input type="radio" name="tabs-demo" id="tab-build" />
<label for="tab-build">Build</label>
<input type="radio" name="tabs-demo" id="tab-ship" />
<label for="tab-ship">Ship</label>
</div>
<div class="tabs-panels">
<div class="tab-panel panel-design">
<p>We start in grayscale and figure out the layout before any color goes near it. Structure first, polish second.</p>
</div>
<div class="tab-panel panel-build">
<p>Hand-written HTML and CSS, no page builder. The markup stays clean enough that the next person can read it.</p>
</div>
<div class="tab-panel panel-ship">
<p>Static output on a CDN, so the page just appears. Then we hand you the keys and the docs to go with them.</p>
</div>
</div>
</div>CSS
.tabs-demo {
border: 1px solid var(--color-gray-200);
background: var(--color-white);
}
.tabs-row {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-gray-200);
padding: 0.5rem 0.5rem 0;
}
/* The radios do the state-keeping; we hide them but keep them focusable. */
.tabs-demo input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.tabs-row label {
padding: 0.6rem 1rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-gray-500);
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs-row label:hover {
color: var(--color-gray-900);
}
/* The label right after the checked radio is the active tab. */
.tabs-demo input:checked + label {
color: var(--color-gray-900);
border-bottom-color: var(--accent);
}
/* Keyboard focus should be visible, since the real input isn't. */
.tabs-demo input:focus-visible + label {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.tab-panel {
display: none;
padding: 1.25rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--color-gray-600);
}
.tab-panel p {
margin: 0;
}
/* Show the panel whose radio is checked. */
.tabs-demo:has(#tab-design:checked) .panel-design,
.tabs-demo:has(#tab-build:checked) .panel-build,
.tabs-demo:has(#tab-ship:checked) .panel-ship {
display: block;
}Click a tab, or focus one and use the arrow keys - that keyboard behavior is free, because they’re radio buttons and the browser already knows how a radio group works.
The whole idea
Three pieces make it go. A radio group keeps the state: same name, so checking one unchecks the rest, and one starts checked.
<div class="tabs">
<input type="radio" name="tabs" id="t1" checked />
<label for="t1">Design</label>
<input type="radio" name="tabs" id="t2" />
<label for="t2">Build</label>
<div class="panel panel-1">…</div>
<div class="panel panel-2">…</div>
</div>
The radios themselves get tucked away - not with display: none, which would take them out of the keyboard order, but moved off into a one-pixel corner so they stay focusable:
.tabs input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
Then CSS reads the checked state and does two things. The label immediately after the checked radio becomes the active tab, using the adjacent-sibling +:
.tabs input:checked + label {
border-bottom: 2px solid var(--accent);
}
And the matching panel shows, using :has() to ask “does this component contain a checked #t1?” from the outside:
.tabs:has(#t1:checked) .panel-1 {
display: block;
}
That’s the entire mechanism. No event listeners, no state variable, nothing to initialize. The one piece worth checking is :has(), since it’s the newest of the three selectors doing the work here:
Browser support · :has()
Support data from MDN’s browser-compat-data project (v8.0.4), sourced from Mozilla. Last checked 2026-07-03.
Solidly in place everywhere - more on why it matters on its own.
What you get for free, and what you don’t
Because these are real radio buttons, the keyboard works the way people expect: arrow keys move between tabs, and the choice is announced. You didn’t write any of that. It’s the same instinct behind an accordion that’s just HTML and components that size themselves with container queries - let the platform carry the weight it was built to carry.
Be honest about what this is, though. It’s a set of radio buttons styled as tabs, not the full ARIA “tablist” pattern that complex web apps use. For panels of static content on a marketing site, that’s exactly right, and it’s sturdier than a hand-rolled script. The day you need panels that load data on demand, or the strict tab semantics an application expects, that’s the day to reach for JavaScript - and not a day sooner. Most of the time, that day doesn’t come.



