Skip to content

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

SlotRequiredDescription
tabsYesRoot container.
tabs-listYesWrapper for triggers. Receives role="tablist".
tabs-triggerYesButton that activates its associated panel. Requires data-value.
tabs-contentYesPanel whose visibility is controlled by its trigger. Requires data-value.
tabs-indicatorNoAnimated 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:

PropertyDescription
--active-tab-leftLeft offset (px) of the active trigger.
--active-tab-widthWidth (px) of the active trigger.
--active-tab-topTop offset (px) of the active trigger.
--active-tab-heightHeight (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

AttributeElement(s)Description
data-stateTriggers, content panels"active" or "inactive".
data-valueRootCurrently selected tab value.
data-activation-directionContent panels (transient)"left", "right", "up", or "down" showing the direction the user navigated. Removed when settled.
roleList, triggers, panels"tablist", "tab", and "tabpanel" respectively.
aria-selectedTriggers"true" or "false".
aria-disabledTriggers"true" when the trigger is disabled.
aria-controlsTriggersPoints to the associated content panel id.
aria-labelledbyContent panelsPoints to the associated trigger id.
aria-orientationList"vertical" when orientation is vertical.
tabindexTriggers0 on the active trigger, -1 on others (roving tabindex).
hiddenContent panelsSet to true on inactive panels.
disabledTriggers (native button)Native disabled on disabled triggers.

Events

Outbound

EventDetailDescription
tabs:change{ value: string }Fired on the root when the selected tab changes.

Inbound

EventDetailDescription
tabs:set{ value: string } or stringDispatch on the root to select a tab.
tabs:select{ value: string } or stringDeprecated. Alias for tabs:set.

API Reference

Options

OptionTypeDefaultDescription
defaultValuestringValue of the initially selected tab. Falls back to the first enabled trigger.
onValueChange(value: string) => voidCalled 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

MemberTypeDescription
select(value)(value: string) => voidSelect the tab identified by value.
valuestring (getter)Currently selected tab value.
updateIndicator()() => voidForce-update the indicator position. Call after layout changes.
destroy()() => voidRemove 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:

KeyBehavior
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.
HomeMove focus to the first enabled trigger. Auto mode: also selects it.
EndMove focus to the last enabled trigger. Auto mode: also selects it.
Enter / SpaceActivate the currently focused trigger (primary activation in manual mode).
ArrowDown from active tabIn 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).