Components
Tabs
CSS-driven tabs using radio buttons with keyboard enhancement via ui-tabs.
Tabs use grouped input[type="radio"][role="tab"] inputs to toggle panels and sliding indicators using pure CSS. Wrap in <ui-tabs class="ui-tabs"> for keyboard navigation (arrow keys, Home/End, wrap-around). Radio button order must match tab panel order.
Default
Loading components…
<!-- Tabs horizontal START --><ui-tabs class="w-full"> <div data-slot="tabs-list" role="tablist"> <div data-slot="tabs-indicator" aria-hidden="true"></div> <input type="radio" role="tab" name="tabs-group-1" id="tab-1" checked /> <label for="tab-1" data-slot="tabs-label"> <span data-slot="tabs-label-text">Account</span> </label> <input type="radio" role="tab" name="tabs-group-1" id="tab-2" /> <label for="tab-2" data-slot="tabs-label"> <span data-slot="tabs-label-text">Billing</span> </label> <input type="radio" role="tab" name="tabs-group-1" id="tab-3" /> <label for="tab-3" data-slot="tabs-label"> <span data-slot="tabs-label-text">Notifications</span> </label> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Account</h2> <p class="text-muted-foreground mt-xs">Manage your profile, email, and password.</p> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Billing</h2> <p class="text-muted-foreground mt-xs">Review invoices and update your payment method.</p> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Notifications</h2> <p class="text-muted-foreground mt-xs">Choose what we email you about and how often.</p> </div></ui-tabs><!-- Tabs horizontal END --><!-- Tabs vertical START --><ui-tabs class="w-full" data-orientation="vertical"> <div data-slot="tabs-list" role="tablist"> <div data-slot="tabs-indicator" aria-hidden="true"></div> <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-1" checked /> <label for="tab-vertical-1" data-slot="tabs-label"> <span data-slot="tabs-label-text">Account</span> </label> <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-2" /> <label for="tab-vertical-2" data-slot="tabs-label"> <span data-slot="tabs-label-text">Billing</span> </label> <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-3" /> <label for="tab-vertical-3" data-slot="tabs-label"> <span data-slot="tabs-label-text">Notifications</span> </label> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Account</h2> <p class="text-muted-foreground mt-xs">Manage your profile, email, and password.</p> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Billing</h2> <p class="text-muted-foreground mt-xs">Review invoices and update your payment method.</p> </div> <div class="border rounded-md p-md w-full" data-slot="tabs-panel" role="tabpanel"> <h2 class="text-lg font-strong">Notifications</h2> <p class="text-muted-foreground mt-xs">Choose what we email you about and how often.</p> </div></ui-tabs><!-- Tabs vertical END -->// primitives/tabs/tabs.js"use strict";/** * @fileoverview `<ui-tabs>`: HTML web component for keyboard-enhanced tabs. * @description Light-DOM custom element that augments the CSS-only radio * tabs pattern with orientation-aware arrow-key navigation. The element * replaces the `.tabs` wrapper `<div>` and carries the same class, so all * existing CSS (panel visibility via `:has()`, the anchor-positioned * indicator) applies unchanged. * * Keyboard behavior on the focused tab radio: * - Horizontal (default): ArrowLeft / ArrowRight move between tabs. * - Vertical (`data-orientation="vertical"`): ArrowUp / ArrowDown move between tabs. * - Home / End jump to the first / last enabled tab. * - Navigation wraps around and skips disabled tabs. * * Native radio-group arrow keys already provide a baseline without * JavaScript; this element makes the keys match the tabs' visual * orientation and adds Home/End + wrap-around. * * @example * <ui-tabs class="tabs"> * <div data-slot="tabs-list" role="tablist"> * <label data-slot="tabs-label"><input type="radio" name="tg" checked />One</label> * <label data-slot="tabs-label"><input type="radio" name="tg" />Two</label> * </div> * <div data-slot="tabs-panel">…</div> * <div data-slot="tabs-panel">…</div> * </ui-tabs> */import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";class UiTabs extends ZazzElement { setup(signal) { this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); } /** * @description Handles arrow-key, Home, and End navigation between tab radios. * * @param event - The keydown event. */ #onKeydown(event) { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "radio") return; const list = target.closest('[role="tablist"], [data-slot~="tabs-list"]'); // Ignore radios that belong to a nested ui-tabs if (!list || list.closest("ui-tabs") !== this) return; const tabs = Array.from(list.querySelectorAll('input[type="radio"]')) .filter((node) => node instanceof HTMLInputElement) .filter((tab) => !tab.disabled); if (tabs.length < 2) return; const vertical = this.getAttribute("data-orientation") === "vertical"; const prevKey = vertical ? "ArrowUp" : "ArrowLeft"; const nextKey = vertical ? "ArrowDown" : "ArrowRight"; const index = tabs.indexOf(target); if (index === -1) return; let nextIndex; switch (event.key) { case prevKey: nextIndex = (index - 1 + tabs.length) % tabs.length; break; case nextKey: nextIndex = (index + 1) % tabs.length; break; case "Home": nextIndex = 0; break; case "End": nextIndex = tabs.length - 1; break; default: return; } event.preventDefault(); const tab = tabs[nextIndex]; tab.checked = true; tab.focus(); tab.dispatchEvent(new Event("change", { bubbles: true })); }}defineZazzElement("ui-tabs", UiTabs);export { UiTabs };/** * tabs.css — Tabs (ui-tabs | .ui-tabs) — CSS-only radio tab panels * * @layer variables, components * @requires layers.css, _variables.css * @uses input[type="radio"] + :checked — panel visibility via :has() (no JS) * @uses anchor-name, position-anchor, anchor() — sliding indicator pill * (@supports gated; checked-label fallback paints card fill without pill) * @uses anchor-scope — isolates --active-tab inside nested menus * @uses text-box, color-mix() — label trim and focus ring * @tokens --ui-tabs-* (@layer variables) */@layer variables { :root { --ui-tabs-gap: var(--gap-xs); --ui-tabs-track-padding: var(--step-0_5); --ui-tabs-track-radius: var(--radius-md); --ui-tabs-track-gap: var(--step-0_5); --ui-tabs-track-background: var(--muted); --ui-tabs-indicator-background: var(--card); --ui-tabs-indicator-radius: var(--radius-sm); --ui-tabs-indicator-shadow: var(--shadow-xs); --ui-tabs-indicator-inset: var(--ui-tabs-track-padding); --ui-tabs-label-font-family: var(--font-family-body); --ui-tabs-label-font-size: var(--font-size-sm); --ui-tabs-label-font-weight: var(--font-weight-strong); --ui-tabs-label-line-height: 1; --ui-tabs-label-background: transparent; --ui-tabs-label-background--hover: transparent; --ui-tabs-label-background--active: transparent; --ui-tabs-label-foreground: var(--muted-foreground); --ui-tabs-label-foreground--hover: var(--foreground); --ui-tabs-label-foreground--active: var(--foreground); --ui-tabs-label-padding: var(--step-2_5); --ui-tabs-label-min-height: var(--step-7); --ui-tabs-label-ring-color: var(--ring-color); }}@layer zazz.components { /* =========================================================================== TABS — :where(ui-tabs, .ui-tabs) [data-slot~="tabs-list"] — inline-flex muted track (fits tab row only; panels live outside). [data-slot~="tabs-indicator"] — card pill anchored to checked label. Panels — direct :where(ui-tabs, .ui-tabs) children after [data-slot~="tabs-list"]; order must match tab order. Panel N is shown when the Nth radio in [data-slot~="tabs-list"] is checked (see rules below). =========================================================================== */ :where(ui-tabs, .ui-tabs) { display: flex; flex-direction: column; align-items: start; gap: var(--ui-tabs-gap); } [data-slot~="tabs-list"] { position: relative; display: inline-flex; align-items: center; gap: var(--ui-tabs-track-gap); padding: var(--ui-tabs-track-padding); border-radius: var(--ui-tabs-track-radius); background-color: var(--ui-tabs-track-background); /* Scoped so [data-slot~="tabs-indicator"] can resolve --active-tab even when an ancestor (e.g. [data-slot~="navigation-menu-item"]) sets anchor-scope to its own trigger name. */ anchor-scope: --active-tab; } [data-slot~="tabs-indicator"] { position: absolute; z-index: 0; pointer-events: none; border-radius: var(--ui-tabs-indicator-radius); background-color: var(--ui-tabs-indicator-background); box-shadow: var(--ui-tabs-indicator-shadow); transition-property: left, right, top, bottom; transition-duration: var(--default-transition-duration); transition-timing-function: var(--default-transition-timing-function); will-change: left, right, top, bottom; } [data-slot~="tabs-list"] input[type="radio"] { position: absolute; clip-path: inset(50%); opacity: 0; pointer-events: none; } [data-slot~="tabs-list"] [data-slot~="tabs-label"] { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; font-family: var(--ui-tabs-label-font-family); font-size: var(--ui-tabs-label-font-size); font-weight: var(--ui-tabs-label-font-weight); line-height: var(--ui-tabs-label-line-height); min-block-size: var(--ui-tabs-label-min-height); padding-inline: var(--ui-tabs-label-padding); color: var(--ui-tabs-label-foreground); background-color: var(--ui-tabs-label-background); text-wrap: balance; cursor: pointer; user-select: none; text-box: trim-both ex alphabetic; border-radius: var(--ui-tabs-indicator-radius); /* ring renders as box-shadow so it composes with the indicator shadow in the no-anchor fallback below; the ring widths animate from 0 so the ring grows in smoothly on focus */ --_ring-offset-width: 0px; --_ring-width: 0px; --_ring: color-mix(in oklch, var(--ui-tabs-label-ring-color) var(--ring-opacity), transparent); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)); /* transparent twin of the ring for forced-colors / high-contrast modes */ outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } [data-slot~="tabs-list"] [data-slot~="tabs-label"]:hover { color: var(--ui-tabs-label-foreground--hover); background-color: var(--ui-tabs-label-background--hover); } [data-slot~="tabs-list"] [data-slot~="tabs-label"]:active { color: var(--ui-tabs-label-foreground--active); background-color: var(--ui-tabs-label-background--active); } [data-slot~="tabs-list"] input[type="radio"]:checked + [data-slot~="tabs-label"] { color: var(--ui-tabs-label-foreground--active); anchor-name: --active-tab; } [data-slot~="tabs-list"] input[type="radio"]:focus-visible + [data-slot~="tabs-label"] { --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } /* =========================================================================== INDICATOR POSITIONING — anchor positioning (Chromium 125+) with a paint fallback. The pill sizes itself from the checked label via anchor() insets; the checked label exposes itself as --active-tab. [data-slot~="tabs-list"] sets anchor-scope: --active-tab so the pill still resolves inside scoped ancestors (navigation menu items scope their trigger name). Where anchor positioning is unavailable (Firefox, Safari) the anchor() insets would be invalid and the pill would collapse to nothing — leaving the active tab with no affordance, as its label background is transparent by design. So in the fallback we hide the floating pill and paint the checked label (card fill + shadow) directly. The trade-off is the loss of the slide animation, not the active-state indication. =========================================================================== */ @supports (anchor-name: --active-tab) { [data-slot~="tabs-indicator"] { position-anchor: --active-tab; top: calc(anchor(--active-tab top) + var(--ui-tabs-indicator-inset)); left: calc(anchor(--active-tab left) + var(--ui-tabs-indicator-inset)); right: calc(anchor(--active-tab right) + var(--ui-tabs-indicator-inset)); bottom: calc(anchor(--active-tab bottom) + var(--ui-tabs-indicator-inset)); } [data-slot~="tabs-list"] input[type="radio"]:checked + [data-slot~="tabs-label"] { background-color: var(--ui-tabs-label-background--active); } } @supports not (anchor-name: --active-tab) { [data-slot~="tabs-indicator"] { display: none; } [data-slot~="tabs-list"] input[type="radio"]:checked + [data-slot~="tabs-label"] { border-radius: var(--ui-tabs-indicator-radius); background-color: var(--ui-tabs-indicator-background); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)), var(--ui-tabs-indicator-shadow); } } /* Nth radio in [data-slot~="tabs-list"] → (N + 1)th child of :where(ui-tabs, .ui-tabs) (after [data-slot~="tabs-list"]). The per-index enumeration below (capped at 24 tabs) is irreducible in pure CSS: it correlates the checked radio's position with a panel's position, and selectors cannot do index arithmetic. sibling-index()/sibling-count() (Baseline 2026) do NOT replace it — they are value functions (<integer> in calc() on the element they're used on), unusable inside selectors, and no mechanism carries the checked radio's index out of its own subtree (custom properties only inherit down; only :has() looks up, and it can't count). Hiding must stay display:none (accessibility: opacity/size tricks leave panels focusable), so a value-level rewrite has nowhere to land. */ :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(1):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(2), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(2):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(3), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(3):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(4), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(4):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(5), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(5):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(6), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(6):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(7), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(7):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(8), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(8):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(9), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(9):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(10), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(10):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(11), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(11):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(12), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(12):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(13), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(13):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(14), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(14):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(15), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(15):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(16), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(16):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(17), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(17):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(18), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(18):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(19), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(19):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(20), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(20):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(21), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(21):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(22), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(22):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(23), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(23):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(24), :where(ui-tabs, .ui-tabs):has( [data-slot~="tabs-list"] > input[type="radio"]:nth-of-type(24):not(:checked) ) > [data-slot~="tabs-panel"]:nth-child(25) { display: none !important; } /* =========================================================================== DIRECTIONAL VARIANTS =========================================================================== */ :where(ui-tabs, .ui-tabs)[data-orientation="vertical"] { flex-direction: row; } :where(ui-tabs, .ui-tabs)[data-orientation="vertical"] [data-slot~="tabs-list"] { flex-direction: column; align-items: stretch; min-inline-size: var(--step-44); } :where(ui-tabs, .ui-tabs)[data-orientation="vertical"] [data-slot~="tabs-label"] { text-align: start; justify-content: start; }}API
| Attribute / Element | Placement | Values |
|---|---|---|
<ui-tabs> | Root | Custom element root; adds keyboard navigation |
data-orientation | Root | vertical (horizontal by default) |