@versini/ui-tabs
v1.4.4
Published
Readme
@versini/ui-tabs
An accessible, zero-layout-shift React tabs component built with TypeScript and TailwindCSS.
The Tabs component provides a fully WAI-ARIA-compliant tab set: keyboard navigation, roving tabindex, automatic/manual activation, horizontal/vertical orientation, and a no-layout-shift active state (the active tab can go bold without nudging its neighbors). It is hand-rolled with no third-party runtime dependency to keep the bundle small.
Table of Contents
Features
- ♿ Fully accessible:
tablist/tab/tabpanelroles,aria-selected,aria-controls/aria-labelledby, roving tabindex, arrow / Home / End keys, automatic or manual activation. - 📐 No layout shift: the active tab goes bold without shifting surrounding tabs (an invisible bold twin reserves the width).
- 🧭 Orientation:
horizontal(default) orvertical. - 🪟 Two variants:
underline(default) leaves the tabs and panel on the host surface;enclosedmakes the whole Tabs one clipped container whose active trigger paints the same surface as the panel, so the two read as a single shape. - 🎨 Surface-aware theming:
mode(system|light|dark|alt-system) keeps text readable even on a surface whose lightness differs from the ambient theme. - 🎛️ Controlled & uncontrolled:
value/onValueChangeordefaultValue. - 🪶 Tiny: no Radix, only
clsx+ three small@versini/ui-hooks. - 🧪 Type safe: generic over the tab value (
Tabs<Value extends string>), so your tab keys stay narrowed.
Installation
npm install @versini/ui-tabsNote: This component requires TailwindCSS and the
@versini/ui-stylesplugin for proper styling. See the installation documentation for complete setup instructions.
Usage
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@versini/ui-tabs";
type TabKey = "details" | "history" | "settings";
export const Example = () => (
<Tabs<TabKey> defaultValue="details">
<TabsList aria-label="Parcel views">
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger value="history">History</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="details">Details panel</TabsContent>
<TabsContent value="history">History panel</TabsContent>
<TabsContent value="settings">Settings panel</TabsContent>
</Tabs>
);Controlled
const [value, setValue] = useState<TabKey>("details");
<Tabs<TabKey> value={value} onValueChange={setValue}>
{/* ... */}
</Tabs>;Note: In uncontrolled mode with no
defaultValue, the first enabled tab is auto-selected after mount andonValueChangefires once to report it. PassdefaultValue(or use controlledvalue) to avoid the mount-time callback.
Navigation / trigger-only
TabsList + TabsTrigger work without any panel component (e.g. route-driven tabs). In that mode no aria-controls is emitted, so it stays valid ARIA.
Keep your panel content in your own switch when it needs its own state or lazy loading: only the active TabsContent is mounted, so panels with unsaved local state should not live inside TabsContent (a forceMount opt-in is planned). TabsPanel covers exactly that case — it stays mounted, is not keyed to a value, and still supplies the panel surface plus the aria-controls / aria-labelledby wiring that a bare tablist cannot.
API
Tabs<Value extends string = string>
| Prop | Type | Default |
|------------------|----------------------------------------|------------------|
| value | Value | (controlled) |
| defaultValue | Value | first enabled trigger |
| onValueChange | (value: Value) => void | — |
| orientation | "horizontal" \| "vertical" | "horizontal" |
| activationMode | "automatic" \| "manual" | "automatic" |
| size | "small" \| "medium" \| "large" | "medium" |
| mode | "system" \| "light" \| "dark" \| "alt-system" | "system" |
| variant | "underline" \| "enclosed" | "underline" |
| className | string | — |
TabsList
role="tablist". Owns keyboard navigation. Pass aria-label or aria-labelledby to give the tablist an accessible name (required by APG; a dev warning fires if missing). Extra props are forwarded.
TabsTrigger<Value extends string = string>
role="tab". Props: value (required, unique within a Tabs), disabled, plus standard button attributes. Labels may be any non-interactive node (text, icon + text); the no-shift twin duplicates the label, so labels must not contain focusable or id-bearing elements.
TabsContent<Value extends string = string>
role="tabpanel". Props: value (required), plus standard div attributes. Renders only when active and a matching trigger exists.
TabsPanel
role="tabpanel". Props: standard div attributes only — it is not keyed to a value. One TabsPanel stays mounted for the whole Tabs and re-labels itself against whichever tab is active, so the consumer keeps deciding what goes inside (lazy-loaded content, panel-local state that must survive a tab change, or a routed <Outlet />).
Use TabsContent or TabsPanel, never both in the same Tabs: they derive their id from the same active value, so together they emit duplicate DOM ids and two tabpanel elements for one tab.
Renders nothing when no trigger owns the active value (including before the first trigger registers, and on the server, where layout effects do not run).
Accessibility
Implements the WAI-ARIA APG Tabs pattern:
- Roving tabindex: exactly one trigger is in the tab order; arrow keys move between tabs (wrapping, skipping disabled), Home/End jump to first/last.
automaticactivation selects on focus;manualrequires Enter/Space.- Activation keeps focus on the trigger;
Tabmoves to the active panel. aria-controlsis emitted only on the active trigger (whose panel is mounted), avoiding dangling references. APG's example wires it on every tab; this component intentionally deviates because inactive panels are unmounted.- The active panel is
tabIndex={0}.
Theming and mode
Text color must match the surface the tabs sit on, which is not always the
ambient theme. Because dark mode is driven by prefers-color-scheme, the dark:
variant follows the OS, not the local surface — so a light card inside a
dark-mode app would otherwise get light text on a light background (unreadable).
mode="system"(default) — follow the ambient theme. Use when the tabs sit on the page surface (which follows the OS).mode="light"— force dark text. Use on a light surface regardless of OS.mode="dark"— force light text. Use on a dark surface regardless of OS.mode="alt-system"— invert the ambient theme.
// A light card that stays light even when the OS is in dark mode:
<div className="bg-surface-light">
<Tabs defaultValue="a" mode="light">…</Tabs>
</div>