Tabs
Tabs — one strip of labels that switches panels in place.
Examples
Your name, avatar and public handle.
Card on file, invoices and the plan you are on.
Which events send you an email.
<div class="tabs" data-ui="tabs">
<div class="tablist" role="tablist" aria-label="Account settings">
<button class="tab" type="button" role="tab" id="tab-profile"
aria-controls="panel-profile" aria-selected="true">Profile</button>
<button class="tab" type="button" role="tab" id="tab-billing"
aria-controls="panel-billing" aria-selected="false">Billing</button>
<button class="tab" type="button" role="tab" id="tab-alerts"
aria-controls="panel-alerts" aria-selected="false">Notifications</button>
</div>
<div class="tabpanel" role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<p>Your name, avatar and public handle.</p>
</div>
<div class="tabpanel" role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" hidden>
<p>Card on file, invoices and the plan you are on.</p>
</div>
<div class="tabpanel" role="tabpanel" id="panel-alerts" aria-labelledby="tab-alerts" hidden>
<p>Which events send you an email.</p>
</div>
</div>The roles and data-ui="tabs" are one indivisible thing. role="tablist" tells a keyboard user the arrow keys move between tabs — a promise only the script keeps, so copying the roles without it produces a widget that announces a route and dead-ends.
Profile
Your name, avatar and public handle.
Billing
Card on file, invoices and the plan you are on.
<div class="tabs">
<nav class="tablist" aria-label="Account settings">
<a class="tab" href="#profile">Profile</a>
<a class="tab" href="#billing">Billing</a>
<a class="tab" href="#alerts">Notifications</a>
</nav>
<section class="tabpanel" id="profile" aria-labelledby="h-profile">
<h3 id="h-profile">Profile</h3>
<p>Your name, avatar and public handle.</p>
</section>
<section class="tabpanel" id="billing" aria-labelledby="h-billing">
<h3 id="h-billing">Billing</h3>
<p>Card on file, invoices and the plan you are on.</p>
</section>
</div>Not a degraded version of the first — a correct component. No roles, no script, nothing claimed: Tab walks the links, Enter jumps, find-in-page finds every word.
If you are not shipping the JavaScript, this is the one you want.
Tokens 20
Level 2, declared on .tabs itself. Set any of them on that selector to restyle this component without touching the skin.
| Token | Default |
|---|---|
| --tab-bg | transparent |
| --tab-color | var(--color-on-surface-muted) |
| --tab-font-size | var(--font-size-sm) |
| --tab-font-weight | var(--font-weight-medium) |
| --tab-gap | var(--space-2) |
| --tab-hover-bg | transparent |
| --tab-hover-color | var(--color-on-surface) |
| --tab-indicator-color | var(--color-primary) |
| --tab-indicator-width | 2px |
| --tab-min-size | 2.75rem |
| --tab-padding-block | var(--space-3) |
| --tab-padding-inline | var(--space-4) |
| --tab-radius | var(--radius-field) |
| --tab-selected-bg | transparent |
| --tab-selected-color | var(--color-primary) |
| --tablist-gap | var(--space-1) |
| --tablist-rail-color | var(--color-border) |
| --tablist-rail-width | var(--border-width) |
| --tabpanel-padding-block | var(--space-1) |
| --tabs-gap | var(--space-4) |
Variants and states
Variants
None. It is one shape, and the page does the rest.
Inside it
.tab.tabs-vertical.tablist.tabpanel.tabs-pill.tabs-even
State it reads
Read from the platform, never mirrored into a class that could disagree with it.
:disabled:focus:hoverdata-legacy
Before you ship it
This one needs a script. Opt the markup in with data-ui="tabs" and import import 'mostlycss/js/components/tabs' — that specifier and not the package root, which is a bundle carrying a second copy of the registry, so a page holding both initialises every element twice. What you lose without the script is listed below — for some components that is only polish, for others it is the whole interaction.
What you have to do 1 requirements
- Pick one of the two markups above and copy all of it. Then: Name the strip.
aria-labelon therole="tablist", or on the<nav>. A page with two of these and no names announces "tab list" twice. In markup 1, wire the pairs. Every tab needsaria-controlspointing at its panel's id and every panelaria-labelledbypointing back at its tab.tabs.tslogs an error for a tab whosearia-controlsresolves to nothing, because selecting it would switch nothing. Do not put a heading inside arole="tabpanel"that repeats its tab. The panel is already labelled by the tab. Markup 2 is the opposite: its sections carry the headings, because there are no tabs to borrow a name from.
src/css/components/tabs.css · npx mostlycss add tabs