Skip to content

Tabs

A row of tabs that each show one TabPanel.

import { TabPanel, Tabs } from '@zitdevs/ui';

These demos are server HTML with no React on the page. The page runs one script, enhanceTabs(document) from @zitdevs/ui/enhance, exactly as the Astro website does. Click a tab, or focus one and use the arrow keys, Home and End.

Step-by-step guides for running IT in a growing company.
Step-by-step guides for running IT in a growing company.
<Tabs
label="Filter resources"
defaultValue="tab-guides"
items={[
{ id: 'tab-all', label: 'All', count: '24', panelId: 'panel-all' },
{ id: 'tab-guides', label: 'Guides', count: '8', panelId: 'panel-guides' },
]}
/>
<TabPanel id="panel-all" tabId="tab-all">…</TabPanel>
<TabPanel id="panel-guides" tabId="tab-guides" selected>…</TabPanel>

A caller that holds the selection passes value and onValueChange: a click asks, and the tab changes when value does. The panels may come from anywhere — a Server Component’s panels follow too. See Interactive components for a live example.

'use client';
const [tab, setTab] = useState('tab-all');
<Tabs label="Filter resources" items={items} value={tab} onValueChange={setTab} />;

Tabs that filter one grid may all point at the same panel. It stays visible, named by whichever tab is selected, and the page filters it on zd:tabchange:

import { TAB_EVENT, type TabChangeDetail } from '@zitdevs/ui/enhance';
grid.ownerDocument.addEventListener(TAB_EVENT, (event) => {
const { tabId } = (event as CustomEvent<TabChangeDetail>).detail;
// show the cards of tabId
});
Prop Type Default Notes
label string required Names the tab list, such as “Filter resources”.
items readonly TabItem[] required { id, label, count?, panelId }. count is read as part of the tab’s name.
defaultValue string first tab The tab id selected at first. A new one starts the list over from it.
value string — The selected tab id, for a caller that holds the selection.
onValueChange (tabId: string) => void — The tab the reader asks for. Uncontrolled, it reports a change; controlled, it asks for one.
size 'md' | 'lg' 'md' Tabs 40px / 52px tall.
surface 'light' | 'dark' 'light' dark for the inverse and the primary band.
ref Ref<HTMLDivElement> — The tab list.
className string — Placement, on the tab list.
anything else <div> attributes — On the tab list.

Exported types: TabsProps, TabItem. From @zitdevs/ui/enhance: enhanceTabs, TAB_EVENT, TabChangeDetail, TabsOptions, TabsEnhancement.

  • The ARIA tabs pattern with automatic activation: a click or an arrow key selects a tab and shows its panel. Left and Right wrap; Home and End go to the ends.
  • Only the selected tab is in the tab order (tabindex="0"); the others are -1, so Tab moves from the list straight into the panel.
  • Each tab names its panel with aria-controls, and the panel is named by its tab.
  • Every tab is a <button type="button">: it never submits a form.
  • The row scrolls sideways when it does not fit, so the focus ring is drawn inwards, where the scroll container cannot clip it. Forced-colours modes paint its outline.

The server renders the first state: the selected tab, and its panel shown, with the others in the document but hidden. Until an enhancer runs, the other tabs do nothing. Where every panel must be reachable with no script at all, give each view its own URL instead (?tab=guides) and render the links.

Switching needs the enhancer, written once and run two ways:

  • React: nothing to do. The tab list is a small client part that runs the enhancer once it has hydrated. In Next.js, Tabs works from a Server Component; only that part ships to the browser.
  • No React (Astro, server templates): load @zitdevs/ui/enhance and call enhanceTabs(document) once. It finds every [data-tabs] list and enhances each one once.

muted, ink, primary and border-default on a light field; on-inverse-quiet, on-inverse, accent and primary-inverse on a dark one; the inset focus ring.

Use instead: several answers open at once → Accordion.