@versini/ui-tabs
v1.3.2
Published
[](https://www.npmjs.com/package/@versini/ui-tabs) . 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. - 🎨 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 TabsContent (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).
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" |
| 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.
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>