Components
Tabs
The Tabs are radio inputs and the Panels are just divs that show and hide
based on the radio inputs' :checked state.
Full support Supported since v123. Full support Supported since v120. Full support Supported since v17.5.
Basics
Profile panel.
Settings panel
Notifications panel
<div class="tabs underlined" role="tablist"> <input type="radio" name="basic-tabs-html" id="tab-profile" class="tab-input" checked aria-controls="panel-profile"> <label for="tab-profile" class="tab-label" role="tab">Profile</label> <div id="panel-profile" class="tab-panel" role="tabpanel" aria-labelledby="tab-profile"> Profile panel. </div>
<input type="radio" name="basic-tabs-html" id="tab-settings" class="tab-input" aria-controls="panel-settings"> <label for="tab-settings" class="tab-label" role="tab">Settings</label> <div id="panel-settings" class="tab-panel" role="tabpanel" aria-labelledby="tab-settings"> Settings panel </div>
<input type="radio" name="basic-tabs-html" id="tab-notifications" class="tab-input" aria-controls="panel-notifications"> <label for="tab-notifications" class="tab-label" role="tab">Notifications</label> <div id="panel-notifications" class="tab-panel" role="tabpanel" aria-labelledby="tab-notifications"> Notifications panel </div></div>Accessibility
The tab system uses standard radio inputs and labels, so we get group management and keyboard support for free!
Tab List
| Element | Attribute | Description |
|---|---|---|
.tabs | role="tablist" | Identifies the element as a container for a set of tabs. |
input | name | Groups the radio buttons together for exclusive selection. |
label | role="tab" | Identifies the element as a tab to assistive technology. |
Tab Panel
The content area associated with a tab:
| Attribute | Value | Description |
|---|---|---|
role | "tabpanel" | Identifies the element as a tab panel. |
aria-labelledby | string | Links the panel to its trigger ID. |
Keyboard Interaction
- Tab: Moves focus to the active tab trigger (the radio button). Pressing Tab again moves focus out of the tab list to the next focusable element.
- Right Arrow / Down Arrow: Moves focus to the next tab and activates it.
- Left Arrow / Up Arrow: Moves focus to the previous tab and activates it.
API
Tabs container
The container that organizes the tab grid.
| Attribute | Value | Description |
|---|---|---|
class | "tabs" | Required class for the parent container. |
role | "tablist" | Required for accessibility. |
class | "underlined" | Optional variant class. |
Tab State (input)
A hidden radio button that manages the selection state.
| Attribute | Value | Description |
|---|---|---|
type | "radio" | Required for state management. |
class | "tab-input" | Required for behavior and styling. |
name | string | Shared across all tabs in the group. |
checked | boolean | Applied to the initially active tab. |
aria-controls | ID | Should match the ID of the corresponding panel. |
Tab Trigger (label)
| Attribute | Value | Description |
|---|---|---|
class | "tab-label" | Required for behavior and styling. |
for | ID | Must match the ID of the radio input. |
role | "tab" | Required for accessibility. |
Tab Panel (div)
| Attribute | Value | Description |
|---|---|---|
class | "tab-panel" | Required for behavior and styling. |
role | "tabpanel" | Required for accessibility. |
aria-labelledby | ID | Should match the ID of the corresponding trigger. |
Browser support
Full support Supported since v123. Full support Supported since v120. Full support Supported since v17.5.
See also the full browser support guide.
Installation
@layer components.root { :where(.tabs) { --_accent-color: var(--primary); --_bg-color: transparent;
align-items: flex-start; display: flex; flex-wrap: wrap; gap: 0 var(--size-1); position: relative;
&>.tab-input[type="radio"] { /* utils > .sr-only */ /* TODO: use mixin for this when available */ block-size: 1px; clip-path: inset(50%); inline-size: 1px; overflow: hidden; position: absolute; white-space: nowrap;
&:checked {
/* Checked Tab */ &+[role="tab"] { border-block-end-color: var(--_accent-color); color: var(--_accent-color);
/* Checked Panel */ &+[role="tabpanel"] { display: block; } } }
&:focus-visible { &+[role="tab"] { --focus-ring-color: var(--text-muted); --focus-ring-offset: calc(-1 * var(--size-2)); outline: var(--focus-ring-width) var(--focus-ring-style) var(--focus-ring-color); outline-offset: var(--focus-ring-offset); } } }
/* Tab */ &>[role="tab"] { align-items: center; background-color: transparent; border-bottom: 2px solid transparent; border-radius: 0; color: var(--text-muted); cursor: pointer; display: inline-flex; font-weight: 600; justify-content: center; line-height: var(--font-lineheight-4); order: 1; padding: var(--size-2) var(--size-3); position: relative; transition: color 0.1s; user-select: none;
&:hover { background-color: light-dark(oklch(from var(--_accent-color) calc(l * 0.75) none h / 5%), oklch(from var(--_accent-color) calc(l * 1.25) none h / 5%)); } }
/* Tab Panel */ &>[role="tabpanel"] { display: none; inline-size: 100%; order: 2; } }}