Components
Tabs
A CSS-first tab group with radio-driven panels and keyboard enhancement.
Tabs are CSS-first: grouped input[type="radio"][role="tab"] inputs drive the panels and
the sliding indicator. Wrap the pattern in <tab-group class="tabs"> to add
orientation-aware keyboard navigation: Left/Right for horizontal tabs, Up/Down for vertical
tabs, plus Home/End and wrap-around. Panel order must match the radio order.
Without JavaScript, the grouped radios still select panels natively. The preview loads the required script automatically; open the JS tab to inspect the custom element.
Default
Loading components…
<!-- Tabs horizontal START --><tab-group class="tabs w-full"> <div class="tabs__list" role="tablist"> <div class="tabs__indicator" aria-hidden="true"></div> <input type="radio" role="tab" name="tabs-group-1" id="tab-1" checked /> <label for="tab-1" class="tabs__label"> <span class="tabs__label-text">Account</span> </label> <input type="radio" role="tab" name="tabs-group-1" id="tab-2" /> <label for="tab-2" class="tabs__label"> <span class="tabs__label-text">Billing</span> </label> <input type="radio" role="tab" name="tabs-group-1" id="tab-3" /> <label for="tab-3" class="tabs__label"> <span class="tabs__label-text">Notifications</span> </label> </div> <div class="tabs__panel border rounded-md p-md w-full" 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="tabs__panel border rounded-md p-md w-full" 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="tabs__panel border rounded-md p-md w-full" 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></tab-group><!-- Tabs horizontal END --><!-- Tabs vertical START --><tab-group class="tabs w-full" data-orientation="vertical"> <div class="tabs__list" role="tablist"> <div class="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" class="tabs__label"> <span class="tabs__label-text">Account</span> </label> <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-2" /> <label for="tab-vertical-2" class="tabs__label"> <span class="tabs__label-text">Billing</span> </label> <input type="radio" role="tab" name="tabs-group-2" id="tab-vertical-3" /> <label for="tab-vertical-3" class="tabs__label"> <span class="tabs__label-text">Notifications</span> </label> </div> <div class="tabs__panel border rounded-md p-md w-full" 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="tabs__panel border rounded-md p-md w-full" 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="tabs__panel border rounded-md p-md w-full" 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></tab-group><!-- Tabs vertical END -->// tabs.js"use strict";/** * @fileoverview `<tab-group>` — 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 * <tab-group class="tabs"> * <div class="tabs__list" role="tablist"> * <label class="tabs__tab"><input type="radio" name="tg" checked />One</label> * <label class="tabs__tab"><input type="radio" name="tg" />Two</label> * </div> * <div class="tabs__panel">…</div> * <div class="tabs__panel">…</div> * </tab-group> */class TabGroup extends HTMLElement { /** @type {AbortController|null} */ #controller = null; connectedCallback() { if (this.#controller) return; this.#controller = new AbortController(); this.addEventListener("keydown", (event) => this.#onKeydown(event), { signal: this.#controller.signal, }); } disconnectedCallback() { this.#controller?.abort(); this.#controller = null; } /** * @description Handles arrow-key, Home, and End navigation between tab radios. * * @param {KeyboardEvent} event - The keydown event. * @returns {void} */ #onKeydown(event) { const target = event.target; if (!(target instanceof HTMLInputElement) || target.type !== "radio") return; const list = target.closest('[role="tablist"], .tabs__list'); // Ignore radios that belong to a nested tab-group if (!list || list.closest("tab-group") !== 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 })); }}// Register the element (guarded against double script loads)if (typeof window !== "undefined" && !customElements.get("tab-group")) { customElements.define("tab-group", TabGroup);}// Attach to window for parity with the other component scripts, and export for// module consumers (loaded for its side effect — the custom-element registration).if (typeof window !== "undefined") { window.TabGroup = TabGroup;}export { TabGroup };/** * _tabs.css — Tabs (.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 --tabs-* (@layer variables) */@layer variables { :root { --tabs-gap: var(--gap-xs); --tabs-track-padding: var(--step-0_5); --tabs-track-radius: var(--radius-md); --tabs-track-gap: var(--step-0_5); --tabs-track-background: var(--muted); --tabs-indicator-background: var(--card); --tabs-indicator-radius: var(--radius-sm); --tabs-indicator-shadow: var(--shadow-xs); --tabs-indicator-inset: var(--tabs-track-padding); --tabs-label-font-family: var(--font-family-body); --tabs-label-font-size: var(--font-size-sm); --tabs-label-font-weight: var(--font-weight-strong); --tabs-label-line-height: 1; --tabs-label-background: transparent; --tabs-label-background--hover: transparent; --tabs-label-background--active: transparent; --tabs-label-foreground: var(--muted-foreground); --tabs-label-foreground--hover: var(--foreground); --tabs-label-foreground--active: var(--foreground); --tabs-label-padding: var(--step-2_5); --tabs-label-min-height: var(--step-7); --tabs-label-ring-color: var(--ring-color); }}@layer zazz.components { /* =========================================================================== TABS — .tabs .tabs__list — inline-flex muted track (fits tab row only; panels live outside). .tabs__indicator — card pill anchored to checked label. Panels — direct .tabs children after .tabs__list; order must match tab order. Panel N is shown when the Nth radio in .tabs__list is checked (see rules below). =========================================================================== */ .tabs { display: flex; flex-direction: column; align-items: start; gap: var(--tabs-gap); } .tabs__list { position: relative; display: inline-flex; align-items: center; gap: var(--tabs-track-gap); padding: var(--tabs-track-padding); border-radius: var(--tabs-track-radius); background-color: var(--tabs-track-background); /* Scoped so .tabs__indicator can resolve --active-tab even when an ancestor (e.g. .navigation-menu__item) sets anchor-scope to its own trigger name. */ anchor-scope: --active-tab; } .tabs__indicator { position: absolute; z-index: 0; pointer-events: none; border-radius: var(--tabs-indicator-radius); background-color: var(--tabs-indicator-background); box-shadow: var(--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; } .tabs__list input[type="radio"] { position: absolute; clip-path: inset(50%); opacity: 0; pointer-events: none; } .tabs__list .tabs__label { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; font-family: var(--tabs-label-font-family); font-size: var(--tabs-label-font-size); font-weight: var(--tabs-label-font-weight); line-height: var(--tabs-label-line-height); min-block-size: var(--tabs-label-min-height); padding-inline: var(--tabs-label-padding); color: var(--tabs-label-foreground); background-color: var(--tabs-label-background); text-wrap: balance; cursor: pointer; user-select: none; text-box: trim-both ex alphabetic; border-radius: var(--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(--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); /* 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); } .tabs__list .tabs__label:hover { color: var(--tabs-label-foreground--hover); background-color: var(--tabs-label-background--hover); } .tabs__list .tabs__label:active { color: var(--tabs-label-foreground--active); background-color: var(--tabs-label-background--active); } .tabs__list input[type="radio"]:checked + .tabs__label { color: var(--tabs-label-foreground--active); anchor-name: --active-tab; } .tabs__list input[type="radio"]:focus-visible + .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. .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) { .tabs__indicator { position-anchor: --active-tab; top: calc(anchor(--active-tab top) + var(--tabs-indicator-inset)); left: calc(anchor(--active-tab left) + var(--tabs-indicator-inset)); right: calc(anchor(--active-tab right) + var(--tabs-indicator-inset)); bottom: calc(anchor(--active-tab bottom) + var(--tabs-indicator-inset)); } .tabs__list input[type="radio"]:checked + .tabs__label { background-color: var(--tabs-label-background--active); } } @supports not (anchor-name: --active-tab) { .tabs__indicator { display: none; } .tabs__list input[type="radio"]:checked + .tabs__label { border-radius: var(--tabs-indicator-radius); background-color: var(--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(--tabs-indicator-shadow); } } /* Nth radio in .tabs__list → (N + 1)th child of .tabs (after .tabs__list) */ .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(1):not(:checked)) > .tabs__panel:nth-child(2), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(2):not(:checked)) > .tabs__panel:nth-child(3), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(3):not(:checked)) > .tabs__panel:nth-child(4), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(4):not(:checked)) > .tabs__panel:nth-child(5), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(5):not(:checked)) > .tabs__panel:nth-child(6), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(6):not(:checked)) > .tabs__panel:nth-child(7), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(7):not(:checked)) > .tabs__panel:nth-child(8), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(8):not(:checked)) > .tabs__panel:nth-child(9), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(9):not(:checked)) > .tabs__panel:nth-child(10), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(10):not(:checked)) > .tabs__panel:nth-child(11), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(11):not(:checked)) > .tabs__panel:nth-child(12), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(12):not(:checked)) > .tabs__panel:nth-child(13), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(13):not(:checked)) > .tabs__panel:nth-child(14), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(14):not(:checked)) > .tabs__panel:nth-child(15), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(15):not(:checked)) > .tabs__panel:nth-child(16), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(16):not(:checked)) > .tabs__panel:nth-child(17), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(17):not(:checked)) > .tabs__panel:nth-child(18), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(18):not(:checked)) > .tabs__panel:nth-child(19), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(19):not(:checked)) > .tabs__panel:nth-child(20), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(20):not(:checked)) > .tabs__panel:nth-child(21), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(21):not(:checked)) > .tabs__panel:nth-child(22), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(22):not(:checked)) > .tabs__panel:nth-child(23), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(23):not(:checked)) > .tabs__panel:nth-child(24), .tabs:has(.tabs__list > input[type="radio"]:nth-of-type(24):not(:checked)) > .tabs__panel:nth-child(25) { display: none !important; } /* =========================================================================== DIRECTIONAL VARIANTS =========================================================================== */ .tabs[data-orientation="vertical"] { flex-direction: row; } .tabs[data-orientation="vertical"] .tabs__list { flex-direction: column; align-items: stretch; min-inline-size: var(--step-44); } .tabs[data-orientation="vertical"] .tabs__label { text-align: start; justify-content: start; }}API
| Attribute | Where | Values |
|---|---|---|
<tab-group> | root | Custom element that carries .tabs |
data-orientation | root | vertical (horizontal is default) |