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 };API
| Attribute | Where | Values |
|---|---|---|
<tab-group> | root | Custom element that carries .tabs |
data-orientation | root | vertical (horizontal is default) |