Tabs
A set of tabbed panels where exactly one panel is visible at a time. Supports keyboard navigation with roving tabindex, horizontal and vertical orientations, and an optional animated indicator that tracks the active tab.
import { Tabs } from "@areia/slots";
document.querySelector("#root")!.innerHTML = `
<div data-slot="tabs" class="w-80">
<div data-slot="tabs-list" class="flex gap-1">
<button data-slot="tabs-trigger" data-value="tab-1" class="win95-button border-b-0 data-[state=active]:bg-white">
Tab One
</button>
<button data-slot="tabs-trigger" data-value="tab-2" class="win95-button border-b-0 data-[state=active]:bg-white">
Tab Two
</button>
<button data-slot="tabs-trigger" data-value="tab-3" class="win95-button border-b-0 data-[state=active]:bg-white">
Tab Three
</button>
</div>
<div data-slot="tabs-content" data-value="tab-1" class="win95-inset -mt-0.5 min-h-20 p-3">
Content for tab one.
</div>
<div data-slot="tabs-content" data-value="tab-2" class="win95-inset -mt-0.5 min-h-20 p-3">
Content for tab two.
</div>
<div data-slot="tabs-content" data-value="tab-3" class="win95-inset -mt-0.5 min-h-20 p-3">
Content for tab three.
</div>
</div>
`;
const root = document.querySelector('[data-slot="tabs"]')!;
const controller = Tabs.createTabs(root, {
defaultValue: "tab-1",
orientation: "horizontal",
});
Import
import { Tabs } from "@areia/slots";
Usage
import { Tabs } from "@areia/slots";
// Auto-bind a single root
const root = document.querySelector('[data-slot="tabs"]');
const controller = Tabs.createTabs(root, {
defaultValue: "tab-1",
activationMode: "auto",
});
// Listen for tab changes
root.addEventListener("tabs:change", (e) => {
console.log(e.detail.value);
});
// Auto-bind all unbound roots in scope
const controllers = Tabs.create();
Expected Markup
The tabs component expects a tabs-list containing tabs-trigger elements, and a set of tabs-content panels. Each trigger and content panel must have a matching data-value attribute. An optional tabs-indicator slot provides an animated highlight that follows the active tab.
<div data-slot="tabs">
<div data-slot="tabs-list">
<button data-slot="tabs-trigger" data-value="tab-1">
Tab One
</button>
<button data-slot="tabs-trigger" data-value="tab-2">
Tab Two
</button>
<button data-slot="tabs-trigger" data-value="tab-3">
Tab Three
</button>
<div data-slot="tabs-indicator"></div>
</div>
<div data-slot="tabs-content" data-value="tab-1">
Content for tab one.
</div>
<div data-slot="tabs-content" data-value="tab-2">
Content for tab two.
</div>
<div data-slot="tabs-content" data-value="tab-3">
Content for tab three.
</div>
</div>
Data Slots
| Slot | Required | Description |
|---|---|---|
tabs | Yes | Root container. |
tabs-list | Yes | Wrapper for triggers. Receives role="tablist". |
tabs-trigger | Yes | Button that activates its associated panel. Requires data-value. |
tabs-content | Yes | Panel whose visibility is controlled by its trigger. Requires data-value. |
tabs-indicator | No | Animated highlight that tracks the active trigger. |
CSS Custom Properties (Indicator)
When the tabs-indicator element is present, the controller sets the following CSS custom properties so you can animate its position and size:
| Property | Description |
|---|---|
--active-tab-left | Left offset (px) of the active trigger. |
--active-tab-width | Width (px) of the active trigger. |
--active-tab-top | Top offset (px) of the active trigger. |
--active-tab-height | Height (px) of the active trigger. |
Use these variables with CSS transitions for smooth indicator movement:
[data-slot="tabs-indicator"] {
position: absolute;
left: var(--active-tab-left);
width: var(--active-tab-width);
top: var(--active-tab-top);
height: var(--active-tab-height);
transition:
left 200ms ease,
width 200ms ease;
}
The indicator automatically updates on window resize, list scroll, and layout changes (via ResizeObserver).
Generated Attributes
| Attribute | Element(s) | Description |
|---|---|---|
data-state | Triggers, content panels | "active" or "inactive". |
data-value | Root | Currently selected tab value. |
data-activation-direction | Content panels (transient) | "left", "right", "up", or "down" showing the direction the user navigated. Removed when settled. |
role | List, triggers, panels | "tablist", "tab", and "tabpanel" respectively. |
aria-selected | Triggers | "true" or "false". |
aria-disabled | Triggers | "true" when the trigger is disabled. |
aria-controls | Triggers | Points to the associated content panel id. |
aria-labelledby | Content panels | Points to the associated trigger id. |
aria-orientation | List | "vertical" when orientation is vertical. |
tabindex | Triggers | 0 on the active trigger, -1 on others (roving tabindex). |
hidden | Content panels | Set to true on inactive panels. |
disabled | Triggers (native button) | Native disabled on disabled triggers. |
Events
Outbound
| Event | Detail | Description |
|---|---|---|
tabs:change | { value: string } | Fired on the root when the selected tab changes. |
Inbound
| Event | Detail | Description |
|---|---|---|
tabs:set | { value: string } or string | Dispatch on the root to select a tab. |
tabs:select | { value: string } or string | Deprecated. Alias for tabs:set. |
API Reference
Options
| Option | Type | Default | Description |
|---|---|---|---|
defaultValue | string | — | Value of the initially selected tab. Falls back to the first enabled trigger. |
onValueChange | (value: string) => void | — | Called when the selected tab changes. |
orientation | "horizontal" | "vertical" | "horizontal" | Layout direction. Controls arrow key axis and ARIA orientation. |
activationMode | "auto" | "manual" | "auto" | How tabs are activated during keyboard navigation. "auto" selects on arrow keys; "manual" moves focus only and requires Enter/Space to select. |
Controller
| Member | Type | Description |
|---|---|---|
select(value) | (value: string) => void | Select the tab identified by value. |
value | string (getter) | Currently selected tab value. |
updateIndicator() | () => void | Force-update the indicator position. Call after layout changes. |
destroy() | () => void | Remove all listeners, observers, and clean up. |
Controller
The controller is returned by Tabs.createTabs() and provides imperative control over the selection state.
const controller = Tabs.createTabs(root, {
defaultValue: "tab-1",
});
// Select a tab programmatically
controller.select("tab-2");
// Read current state
console.log(controller.value); // "tab-2"
// Force-update indicator after external layout changes
controller.updateIndicator();
// Clean up
controller.destroy();
Keyboard Interaction
The tab list supports full keyboard navigation with roving tabindex:
| Key | Behavior |
|---|---|
| ArrowLeft (horizontal) | Move focus to the previous enabled trigger. Auto mode: also selects it. |
| ArrowRight (horizontal) | Move focus to the next enabled trigger. Auto mode: also selects it. |
| ArrowUp (vertical) | Move focus to the previous enabled trigger. Auto mode: also selects it. |
| ArrowDown (vertical) | Move focus to the next enabled trigger. Auto mode: also selects it. |
| Home | Move focus to the first enabled trigger. Auto mode: also selects it. |
| End | Move focus to the last enabled trigger. Auto mode: also selects it. |
| Enter / Space | Activate the currently focused trigger (primary activation in manual mode). |
| ArrowDown from active tab | In horizontal mode, moves focus into the active panel's first focusable element. |
Navigation wraps from the last trigger to the first and vice versa. Disabled triggers are skipped entirely.
Activation Modes
"auto"(default): Arrow keys immediately select the tab as focus moves. This is the typical behavior users expect from tabs."manual": Arrow keys move focus only. The user must press Enter or Space to activate the focused tab. Use this when tab changes have significant side effects (e.g., loading data).