CSS Tab Navigation
Tabbed interface using radio inputs and the :checked pseudo-class.
CSS tabs are built on one property of radio inputs: only one radio in a group can be checked. That is exactly the rule a tab strip needs, so the state a script would normally hold in a variable lives in the form control instead. :checked matches the active radio, the ~ combinator carries that match to the label and the panel, and the browser does the switching.
Three versions follow. The base strip uses display: none on the inputs and an underline drawn with a border. The second animates a single indicator bar between tabs instead of moving a border. The third fixes the accessibility hole in both, because a radio hidden with display: none is not focusable, and a tab strip a keyboard cannot reach is not a tab strip.
Click a tab to switch panels
HTML
<div class="tabs">
<input class="tab-inputs" type="radio" name="tabs" id="t1" checked>
<input class="tab-inputs" type="radio" name="tabs" id="t2">
<input class="tab-inputs" type="radio" name="tabs" id="t3">
<div class="tab-labels">
<label class="tab-label" for="t1">Design</label>
<label class="tab-label" for="t2">Code</label>
<label class="tab-label" for="t3">Preview</label>
</div>
<div class="tab-panels">
<div class="tab-panel" id="p1">Design panel content goes here.</div>
<div class="tab-panel" id="p2">Code panel content goes here.</div>
<div class="tab-panel" id="p3">Preview panel content goes here.</div>
</div>
</div>
CSS
.tabs {
display: flex;
flex-direction: column;
width: 320px;
}
.tab-inputs {
display: none;
}
.tab-labels {
display: flex;
border-bottom: 2px solid #2a2a2d;
}
.tab-label {
padding: .6rem 1.1rem;
cursor: pointer;
font-size: .9rem;
font-weight: 500;
color: #88888f;
border-bottom: 2px solid transparent;
margin-bottom: -2px; /* sits the underline on top of the track */
user-select: none;
transition: color .2s;
}
.tab-label:hover {
color: #b8ff57;
}
.tab-panel {
display: none;
padding: 1.25rem 0;
font-size: .9rem;
color: #f0f0f0;
}
#t1:checked ~ .tab-labels label[for="t1"],
#t2:checked ~ .tab-labels label[for="t2"],
#t3:checked ~ .tab-labels label[for="t3"] {
color: #b8ff57;
border-bottom-color: #b8ff57;
}
#t1:checked ~ .tab-panels #p1,
#t2:checked ~ .tab-panels #p2,
#t3:checked ~ .tab-panels #p3 {
display: block;
}
Other ways to build it
Sliding indicator
Instead of switching a border on and off, one bar sits under the strip and moves. Every label is flex: 1, so all three tabs are the same width and the bar can be sized at a third of the strip and stepped along with translateX(100%). Because it is a transform, the movement runs on the compositor and never touches layout. The equal widths are load bearing: give the labels their natural sizes and each step becomes a different distance.
HTML
<div class="stabs">
<input class="stab-input" type="radio" name="slide" id="s1" checked>
<input class="stab-input" type="radio" name="slide" id="s2">
<input class="stab-input" type="radio" name="slide" id="s3">
<div class="stab-labels">
<label class="stab-label" for="s1">Overview</label>
<label class="stab-label" for="s2">Specs</label>
<label class="stab-label" for="s3">Reviews</label>
<span class="stab-slider"></span>
</div>
<div class="stab-panels">
<div class="stab-panel" id="sp1">...</div>
<div class="stab-panel" id="sp2">...</div>
<div class="stab-panel" id="sp3">...</div>
</div>
</div>
CSS
.stab-labels {
position: relative;
display: flex;
border-bottom: 2px solid #2a2a2d;
}
/* equal widths are what make one step = 100% */
.stab-label {
flex: 1;
text-align: center;
padding: .6rem 0;
cursor: pointer;
}
.stab-slider {
position: absolute;
left: 0;
bottom: -2px;
width: calc(100% / 3);
height: 2px;
background: #b8ff57;
transition: transform .3s cubic-bezier(.4, 0, .2, 1);
}
#s2:checked ~ .stab-labels .stab-slider {
transform: translateX(100%);
}
#s3:checked ~ .stab-labels .stab-slider {
transform: translateX(200%);
}
Reachable from the keyboard
The only change from the strip at the top of this page is how the radios are hidden. display: none takes them out of the tab order and makes the component mouse-only. Hidden with position: absolute and opacity: 0 instead, they stay focusable, and what you get back is native radio group behavior: Tab lands on the selected tab, arrow keys move between tabs and switch the panel as they go, and a second Tab leaves the strip rather than walking through it. Click a tab, then press the left and right arrows.
HTML
<div class="ktabs">
<input class="ktab-input" type="radio" name="tab-navigation-keys" id="k1" checked>
<input class="ktab-input" type="radio" name="tab-navigation-keys" id="k2">
<input class="ktab-input" type="radio" name="tab-navigation-keys" id="k3">
<div class="ktab-labels">
<label class="ktab-label" for="k1">Keyboard</label>
<label class="ktab-label" for="k2">Screen reader</label>
<label class="ktab-label" for="k3">Pointer</label>
</div>
<div class="ktab-panels">
<div class="ktab-panel" id="kp1">Arrow keys move the selection because these are radio buttons in one group. That is browser behavior, not CSS.</div>
<div class="ktab-panel" id="kp2">It still announces as a radio group rather than a tab list. Adding role="tab" and aria-selected needs script to keep them in sync.</div>
<div class="ktab-panel" id="kp3">Clicking a label checks its radio, which is the same behavior a label always has.</div>
</div>
</div>
CSS
/* NOT display:none. These stay in the tab order,
which is the whole point of this version. */
.ktab-input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
pointer-events: none;
}
/* the input is invisible, so the ring goes on its label */
#k1:focus-visible ~ .ktab-labels label[for="k1"],
#k2:focus-visible ~ .ktab-labels label[for="k2"],
#k3:focus-visible ~ .ktab-labels label[for="k3"] {
outline: 2px solid #b8ff57;
outline-offset: -2px;
border-radius: 4px;
}
#k1:checked ~ .ktab-labels label[for="k1"],
#k2:checked ~ .ktab-labels label[for="k2"],
#k3:checked ~ .ktab-labels label[for="k3"] {
color: #b8ff57;
border-bottom-color: #b8ff57;
}
#k1:checked ~ .ktab-panels #kp1,
#k2:checked ~ .ktab-panels #kp2,
#k3:checked ~ .ktab-panels #kp3 {
display: block;
}
How it works
Hidden radio inputs store which tab is active. Because only one radio per group can be checked at a time, CSS can target the correct panel and <label> using the sibling combinator. Each <input>, <label>, and panel need matching id/for attributes and sibling relationships in the DOM.
The markup order is dictated by the combinator, not by taste. ~ only reaches later siblings, so every radio has to appear before both the label strip and the panel container, and all three have to share one parent. That is why the inputs are stacked at the top of the component rather than sitting next to the labels they belong to. Nest a radio inside its label for tidiness and the selector stops matching, with no error to explain it.
Each tab needs two rules: one to mark the label active, one to reveal its panel. Both are written as long comma separated selector lists, one clause per tab. There is no way to say "the nth label matching the nth checked input" in CSS, so the list grows linearly with the number of tabs. At three or five tabs that is fine. At twenty it is worth generating, and it is the honest point at which this technique stops paying for itself.
The panels are switched with display: none and display: block, which means only the active one occupies space and the strip does not have to be told how tall to be. It also means the inactive panels are removed from the accessibility tree and from find in page, which is the correct behavior for tabs specifically. Tabs are alternatives to each other, unlike the panels on the accordion, where several can be open at once.
The active underline in the base version is a border-bottom on the label plus margin-bottom: -2px, which lifts the label's own border on top of the strip's border so the two overlap rather than stacking into a 4px line. The sliding version replaces both with one absolutely positioned bar. Because each label is flex: 1, every tab is the same width, so the bar can be sized as a fraction of the strip and moved with translateX(100%) per step. Give the labels their natural widths instead and the translate distances stop being uniform, which is when this approach needs hard-coded pixel values per tab.
:target is the other way to build tabs. Panels keyed to a URL fragment can be linked to and survive a reload, which radios cannot, and the same trade is covered on show hide content. The cost is that following a tab link scrolls the page to the panel, which is disruptive in the middle of an article. For a vertical arrangement of the same technique, see vertical tabs.
CSS properties used
:checked- Matches the active radio. Everything else in the component keys off this one state.
~- The general sibling combinator. Carries the
:checkedmatch forward to the label strip and the panel container. displaynoneandblockon the panels. Only the active panel takes up space, so the component sizes itself to whatever is showing.flexflex: 1on each label makes every tab the same width, which is what lets the sliding indicator move in equal steps.transformtranslateX()moves the indicator bar between tabs on the compositor, without touching layout.:focus-visible- Puts the focus ring on the label, since the radio driving it is visually hidden and would show no focus of its own.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
:checked and ~ | 4 | 3.5 | 3.2 | 12 |
flexbox | 21 | 28 | 6.1 | 12 |
transform | 4 | 3.5 | 3.1 | 12 |
transition | 4 | 5 | 5.1 | 12 |
:focus-visible | 86 | 4 | 15.4 | 86 |
Figures come from Can I Use for CSS3 selectors, the Flexible Box Layout module, 2D transforms, transitions, and the :focus-visible pseudo-class. Nothing here needs a fallback. :focus-visible is the newest and it degrades cleanly: a browser that does not know it drops the rule, so the focus ring falls back to whatever :focus provides rather than disappearing, as long as you have not removed the default outline elsewhere.
Accessibility notes
display: none on a radio input removes it from the tab order. That is the whole accessibility problem with the copy-and-paste version of this pattern, and it is invisible until someone tries to use it without a mouse. The fix is one rule: hide the input with position: absolute; opacity: 0; width: 1px; height: 1px so it keeps focus, then draw the focus ring on the label with :focus-visible. The keyboard variant below does exactly that and nothing else changes.
With the inputs focusable, the behavior you get is native radio group behavior, and it happens to match how tabs are supposed to work. Tab enters the group once and lands on the checked radio. Arrow keys move between tabs, selecting as they go, which is the automatic activation model the ARIA authoring practices describe for tabs. Tab again leaves the group entirely rather than walking through every tab. None of that is code you write.
What CSS cannot supply is the vocabulary. A screen reader announces radio buttons in a group, not a tab list with a selected tab, because role="tablist", role="tab", aria-selected and aria-controls are attributes you would have to add by hand, and aria-selected has to change when the selection changes, which needs script. The radio announcement is honest and usable, and it is not the tab pattern. Decide which matters more for the content you are switching between.
Keep the label text as text. An icon-only tab with no accessible name announces as an unlabelled radio button, which tells the reader nothing about what selecting it does.
What you can build with it
- Product page detail switching. Description, specifications and shipping as three tabs, so a long page stays one screen tall without hiding anything behind a second request.
- Code samples in more than one language. The same snippet in curl, JavaScript and Python, switched in place. Panels are alternatives, which is what tabs are for.
- Pricing by billing period. Monthly and annual columns behind a two tab strip rather than a toggle, so the current choice is spelled out rather than inferred from a switch position.
- Dashboard sections. Overview, activity and settings, where each panel is small enough that loading all three up front costs nothing.
- Static site documentation. Platform specific instructions on a page built ahead of time, where adding a script for three tabs is more machinery than the page otherwise needs.
Mistakes worth avoiding
- Hiding the radios with
display: none. It works visually and it silently removes the entire component from the keyboard. Use an off-screen or one pixel transparent input instead. - Nesting the radio inside its own label. It reads as tidier markup and it breaks every selector, because the input stops being a sibling of the panel container.
- Forgetting
checkedon the first radio. The strip loads with no tab active and no panel visible, which looks like a broken component rather than an empty state. - Giving the labels natural widths and then moving a sliding indicator by a fixed percentage. The bar drifts out of alignment on every tab after the first, and the drift changes when the label text is translated.
- Reusing the same
namefor two tab strips on one page. They become one radio group, so selecting a tab in the first strip deselects whatever was active in the second.
Frequently asked questions
How do I make tabs with CSS and no JavaScript?
name, with checked on the first. Follow them with a row of labels whose for attributes point at the radios, and a container of panels. Then write #tab1:checked ~ .panels #panel1 { display: block } for each tab, with the panels hidden by default.Are CSS-only tabs accessible?
role="tab" and aria-selected need script to stay in sync.Why does my tab strip not switch panels?
~ combinator only matches later siblings under a shared parent, so a radio nested inside a label or wrapped in its own div puts the panels out of reach.Can a CSS tab be linked to directly?
:target if a link to a specific tab matters, and accept that following such a link scrolls the page to the panel.