Tabs
This is a CSS-only radio-driven tab switcher. It uses native radio semantics, so assistive technologies expose it as a radio group rather than as ARIA tabs.
0 KB JavaScript
~60 CSS lines
radio driven state
keyboard native radio
Preview
The code
The markup
<fieldset class="tabs">
<legend class="sr-only">Choose which panel to display</legend>
<input type="radio" name="example-tabs" id="example-tab-1" checked>
<input type="radio" name="example-tabs" id="example-tab-2">
<input type="radio" name="example-tabs" id="example-tab-3">
<div class="tab-list">
<label id="example-tab-label-1" for="example-tab-1" class="tab-label">...</label>
<label id="example-tab-label-2" for="example-tab-2" class="tab-label">...</label>
<label id="example-tab-label-3" for="example-tab-3" class="tab-label">...</label>
</div>
<div class="tab-panels">
<div class="tab-panel" data-tab="1" aria-labelledby="example-tab-label-1">...</div>
<div class="tab-panel" data-tab="2" aria-labelledby="example-tab-label-2">...</div>
<div class="tab-panel" data-tab="3" aria-labelledby="example-tab-label-3">...</div>
</div>
</fieldset>CSS mechanisms
/* ----------------------------------------------------------------------------
* Tabs Container
* ---------------------------------------------------------------------------- */
/**
* Outer tabs panel.
*/
.tabs {
min-inline-size: 0;
border: 0;
margin: 0;
padding: 0;
/* add your styles here */
}
/**
* Visually hide the radio inputs that drive the state. They
* remain in the DOM and accessible to assistive tech via the
* associated `<label>` elements.
*/
.tabs > input[type="radio"] {
position: absolute;
opacity: 0;
inline-size: 1px;
block-size: 1px;
}
/* ----------------------------------------------------------------------------
* Tab List & Labels
* ---------------------------------------------------------------------------- */
/**
* Horizontal row of tab labels.
*/
.tab-list {
position: relative;
display: flex;
/* add your styles here */
}
/**
* Each tab label fills 1/N of the row.
*/
.tab-label {
flex: 1;
user-select: none;
/* add your styles here */
}
/**
* Optional hover state for pointer users.
*/
.tab-label:hover {
/* add your styles here */
}
/* ----------------------------------------------------------------------------
* :has() Driven State
* ---------------------------------------------------------------------------- */
/**
* Focus management.
*/
.tabs:has(#example-tab-1:focus-visible) .tab-label[for="example-tab-1"],
.tabs:has(#example-tab-2:focus-visible) .tab-label[for="example-tab-2"],
.tabs:has(#example-tab-3:focus-visible) .tab-label[for="example-tab-3"] {
outline: 2px solid currentColor;
outline-offset: 2px;
}
/**
* Highlight the active tab label (matching the checked radio).
*/
.tabs:has(#example-tab-1:checked) .tab-label[for="example-tab-1"],
.tabs:has(#example-tab-2:checked) .tab-label[for="example-tab-2"],
.tabs:has(#example-tab-3:checked) .tab-label[for="example-tab-3"] {
/* add your styles here */
}
/* ----------------------------------------------------------------------------
* Tab Panels
* ---------------------------------------------------------------------------- */
/**
* Stack all panels in the same grid area so only the active one
* is visible. `grid-template: "stack"` is the named-area shorthand
* that creates a single overlapping cell.
*/
.tab-panels {
display: grid;
grid-template: "stack";
}
/**
* All panels start hidden (faded out and pointer-events disabled
* to prevent click-through).
*/
.tab-panel {
grid-area: stack;
opacity: 0;
visibility: hidden;
pointer-events: none;
transition: opacity 320ms, visibility 320ms allow-discrete;
}
/**
* Active panel matches the checked radio: visible and interactive
* again.
*/
.tabs:has(#example-tab-1:checked) .tab-panel[data-tab="1"],
.tabs:has(#example-tab-2:checked) .tab-panel[data-tab="2"],
.tabs:has(#example-tab-3:checked) .tab-panel[data-tab="3"] {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
/* ----------------------------------------------------------------------------
* Tabs A11y
* ---------------------------------------------------------------------------- */
/**
* Visually hide accessibility-only content while keeping it available
* to assistive technologies.
*/
.sr-only {
position: absolute;
inline-size: 1px;
block-size: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}Benefits
-
01.
Native state
Radio inputs provide the exclusive selection model: only one panel can be active at a time, without JavaScript.
-
02.
A11y baseline
<fieldset>,<legend>, radios, and labels create a usable native control group with keyboard support and accessible names. -
03.
No state sync
The checked radio is the source of truth. CSS reads it with
:has(), so there is no duplicated JS state to maintain. -
04.
Content stays in HTML
All panels remain in the document structure. The component stays readable without JavaScript and easy to progressively style.
Current limitations
-
01.
Not true ARIA tabs
Assistive technologies expose this as a radio group, not as
tablist,tab, andtabpanel. Do not add ARIA tab roles unless JavaScript keeps the states in sync. -
02.
Keyboard model
Keyboard behavior follows native radio controls. It does not provide the full tabs pattern: roving focus,
Home/End, manual activation, or panel focus management. -
03.
Static mapping
Each tab needs matching IDs, labels, panels, and
:has()selectors. This is fine for small examples, but verbose for dynamic or reusable components.