---
title: Tabs
description: Tabbed content navigation
visibility: guest
draft: false
---

# Tabs

The `<x-wirekit::tabs>` component creates accessible tabbed interfaces with keyboard navigation, three visual variants, and automatic ARIA wiring. It follows the [WAI-ARIA Tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).

## Usage

:::preview{title="Tabs with Named Slots"}
<x-wirekit::tabs :items="['general' => 'General', 'security' => 'Security', 'notifications' => 'Notifications']" default="general">
    <x-slot:general>General settings for your account.</x-slot:general>
    <x-slot:security>Security and password options.</x-slot:security>
    <x-slot:notifications>Manage your notification preferences.</x-slot:notifications>
</x-wirekit::tabs>
:::

Tabs use **named slots** where the slot name matches each item's key. This keeps markup clean and avoids the need for separate `<x-tab.panel>` sub-components.

## Width & Layout

Tabs fill their parent width. The tab list stretches across the top, and each panel fills the available space below. Constrain the width via the parent or directly:

```blade
<x-wirekit::tabs class="max-w-2xl" :items="['Tab 1', 'Tab 2']">…</x-wirekit::tabs>
```

## Variants

### Underline (default)

:::preview{title="Underline Variant"}
<x-wirekit::tabs :items="['one' => 'Overview', 'two' => 'Activity']" default="one">
    <x-slot:one>Overview content.</x-slot:one>
    <x-slot:two>Activity content.</x-slot:two>
</x-wirekit::tabs>
:::

#### Active-state demo with four tabs

The accent underline tracks whichever tab the reader has activated — click any tab to move it. The underline is 3 px and bounded by the tab cell, so the active affordance reads clearly without competing with the tablist's own 1 px bottom border.

:::preview{title="Underline tabs — click any tab to move the active indicator"}
<x-wirekit::tabs
    :items="['profile' => 'Profile', 'workspace' => 'Workspace', 'integrations' => 'Integrations', 'billing' => 'Billing']"
    default="profile"
    label="Account sections"
>
    <x-slot:profile>Profile section — name, avatar, locale, timezone.</x-slot:profile>
    <x-slot:workspace>Workspace section — team members, invitation link, default project.</x-slot:workspace>
    <x-slot:integrations>Integrations section — connected providers, webhook endpoints, API keys.</x-slot:integrations>
    <x-slot:billing>Billing section — current plan, payment method, invoices, seat usage.</x-slot:billing>
</x-wirekit::tabs>
:::

### Pills

:::preview{title="Pills Variant"}
<x-wirekit::tabs :items="['one' => 'Overview', 'two' => 'Activity']" default="one" variant="pills">
    <x-slot:one>Overview content.</x-slot:one>
    <x-slot:two>Activity content.</x-slot:two>
</x-wirekit::tabs>
:::

### Bordered

:::preview{title="Bordered Variant"}
<x-wirekit::tabs :items="['one' => 'Overview', 'two' => 'Activity']" default="one" variant="bordered">
    <x-slot:one>Overview content.</x-slot:one>
    <x-slot:two>Activity content.</x-slot:two>
</x-wirekit::tabs>
:::

## Icons & Badges

With the array-of-objects items shape, each tab can carry an `icon` (a leading glyph, rendered decoratively) and/or a `badge` (a trailing count or status chip).

:::preview{title="Tabs with icons and a count badge"}
<x-wirekit::tabs :items="[
    ['key' => 'inbox', 'label' => 'Inbox', 'icon' => 'inbox', 'badge' => 8],
    ['key' => 'sent', 'label' => 'Sent', 'icon' => 'send'],
    ['key' => 'archive', 'label' => 'Archive', 'icon' => 'archive'],
]" default="inbox">
    <x-slot:inbox>You have 8 unread messages.</x-slot:inbox>
    <x-slot:sent>Sent messages appear here.</x-slot:sent>
    <x-slot:archive>Archived messages appear here.</x-slot:archive>
</x-wirekit::tabs>
:::

## Vertical Orientation

Set `orientation="vertical"` to stack the tabs in a column beside their panels — useful for settings screens and side navigation. The keyboard model follows the WAI-ARIA pattern: **Up/Down** arrows navigate a vertical tablist (Left/Right for horizontal).

:::preview{title="Vertical tabs"}
<x-wirekit::tabs orientation="vertical" :items="[
    ['key' => 'profile', 'label' => 'Profile', 'icon' => 'user'],
    ['key' => 'billing', 'label' => 'Billing', 'icon' => 'credit-card'],
    ['key' => 'team', 'label' => 'Team', 'icon' => 'users', 'badge' => 3],
]" default="profile">
    <x-slot:profile>Manage your public profile.</x-slot:profile>
    <x-slot:billing>Update your payment method.</x-slot:billing>
    <x-slot:team>Invite and manage teammates.</x-slot:team>
</x-wirekit::tabs>
:::

## Contract: named slots, NOT `wire:model`

**Tabs render client-only.** Active-tab tracking lives in a private Alpine x-data scope; clicking a tab mutates the local `active` variable and toggles the matching panel via `x-show`. No Livewire property is updated — but every switch **does** dispatch a `wirekit:tab-changed` browser event you can listen for (see [Observing tab changes server-side](#observing-tab-changes-server-side)).

`wire:model="activeTab"` on the component tag is silently dropped into the outer `<div>`'s attribute bag (Livewire only watches `<input>`, `<select>`, `<textarea>` — not divs). On a debug build the component prints a `console.warn` when it detects `wire:model*` on the tag; in production the dropped attribute is fully silent.

**Pass tab content as named slots** whose names match each item's key:

```blade
<x-wirekit::tabs :items="['overview' => 'Overview', 'analytics' => 'Analytics']">
    <x-slot:overview>Overview content here.</x-slot:overview>
    <x-slot:analytics>Analytics content here.</x-slot:analytics>
</x-wirekit::tabs>
```

The component looks up the active slot by key in the panel loop and renders only the matching panel via `x-show`. Slots whose names don't appear in `$items` are ignored.

Two props decide which tab is showing, and they answer different questions:

- **`default`** is the tab to open with. The reader takes it from there, and the server is not consulted again.
- **`active`** is the tab that IS open. Bind it to server state and a change there moves the tabs — useful when something other than a click decides, like a route, a deep link, or a validation error that has to pull the reader back to the tab holding the bad field.

```blade
{{-- 1. Opens on analytics; after that the reader is in charge. --}}
<x-wirekit::tabs :items="$items" default="analytics" />

{{-- 2. The server decides, and keeps deciding. --}}
<x-wirekit::tabs :items="$items" :active="$tab" />
```

To **observe** every switch without controlling it, listen for the `wirekit:tab-changed` event the component dispatches (next section) — you do not need to rebuild the tablist by hand to react to a change.

## Observing tab changes server-side

Tabs render client-side, but every switch dispatches a **namespaced, bubbling browser event** — `wirekit:tab-changed` — so a Livewire component can react without rebuilding the tablist by hand. The event fires on **change only**: not on the initial render, and not when the already-active tab is re-clicked.

The event detail carries both the key and the human label:

| Field | Value |
|-------|-------|
| `tab` | the activated item's key (e.g. `analytics`) |
| `label` | the activated item's label (e.g. `Analytics`) |

Because the event bubbles to any ancestor (and to `window`), listen on a wrapper around the tabs and forward it into your Livewire method:

```blade
{{-- 1. Wrap the tabs and bridge the bubbling event into a Livewire method. --}}
<div x-on:wirekit:tab-changed="$wire.onTabChanged($event.detail.tab)">
    <x-wirekit::tabs :items="$tabs" :default="$active">
        {{-- named slots per item key --}}
    </x-wirekit::tabs>
</div>
```

```php
// 2. Handle the change server-side — persist it, lazy-load data, log analytics, etc.
public function onTabChanged(string $tab): void
{
    $this->activeTab = $tab;
}
```

`wire:model` on the tabs tag still does nothing (tabs are not a form input), so the event is the supported way to observe a change. Rendering stays client-side either way — the panels never round-trip to the server just to switch.

## Server-driven tabs (no panels)

Everything above assumes the browser already holds each panel's content. In a Livewire application the common arrangement is the other one: a bar of tabs above content the **server** renders, where choosing a tab is a round trip and the page comes back different.

`<x-wirekit::tabs>` is the wrong tool there, and not because it is too large. Two of the things it does are actively wrong in that arrangement — it holds the selection, which your server has already decided, and it emits `aria-controls` pointing at panels that do not exist, sending a screen-reader user somewhere there is nothing.

So the bar is available on its own:

:::preview{title="A tab bar the server drives"}
<x-wirekit::stack gap="lg">
    <x-wirekit::tabs.list label="Messages" variant="pills">
        <x-wirekit::tabs.tab :selected="true" badge="12">Unread</x-wirekit::tabs.tab>
        <x-wirekit::tabs.tab>Archived</x-wirekit::tabs.tab>
        <x-wirekit::tabs.tab>Sent</x-wirekit::tabs.tab>
    </x-wirekit::tabs.list>

    <x-wirekit::card>
        <x-wirekit::card.body>
            <x-wirekit::text>Whatever your Livewire component rendered for the current tab goes here — the bar does not wrap it, hide it, or know about it.</x-wirekit::text>
        </x-wirekit::card.body>
    </x-wirekit::card>
</x-wirekit::stack>
:::

In your component, wire each tab to the action that changes the selection:

```blade
{{-- 1. `selected` is a plain server-side boolean. It arrives on every render and is
       the only thing that decides — nothing re-derives it in the browser. --}}
<x-wirekit::tabs.list label="Messages">
    @foreach($folders as $key => $folder)
        <x-wirekit::tabs.tab
            :selected="$key === $current"
            :badge="$folder['unread'] ?: null"
            wire:click="select('{{ $key }}')"
        >{{ $folder['label'] }}</x-wirekit::tabs.tab>
    @endforeach
</x-wirekit::tabs.list>

{{-- 2. Your own content, rendered by the server for whichever tab is current.
       No panel wrapper, no hidden siblings, nothing to keep in sync. --}}
<div>
    @include('folders.'.$current)
</div>
```

### What the bar gives you, and what it deliberately does not

It carries the full keyboard model — arrow keys along the orientation, `Home` and `End`, and a roving `tabindex` so the bar is one stop in the page's tab order rather than one stop per tab. It resolves the tabs from the DOM on every keypress rather than remembering them, which is what lets it survive Livewire replacing the markup underneath it.

**Activation is manual, on purpose.** Arrow keys move focus; `Enter` or `Space` commits. Selection following focus is the nicer behavior when switching costs nothing — here it costs a request, and arrowing across five tabs would fire five round trips and render four pages nobody asked to see.

It emits no `aria-controls` and no `role="tabpanel"`, because you own the content and it is not a panel.

::: info Which one to reach for
Content already in the browser, switching instantly — use `<x-wirekit::tabs>`. Content the server renders per selection — use `<x-wirekit::tabs.list>` with `<x-wirekit::tabs.tab>`. Both bars are styled from the same source, so `variant` and `orientation` mean exactly the same thing in either.
:::

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `array` | `[]` | Tab list. Two shapes accepted: **keyed-assoc** (`['profile' => 'Profile']`) OR **array-of-objects** (`[['key' => 'profile', 'label' => 'Profile', 'icon' => 'user', 'badge' => 3]]`). Only the array-of-objects shape can carry a per-tab `icon` (leading glyph) and `badge` (trailing count/chip). The component normalizes both at the template edge. When `label` is missing in array-of-objects form, the `key` is used as the visible label. |
| `default` | `string\|null` | first key | Initially active tab key |
| `active` | `string\|null` | `null` | The active tab **as the server sees it**. Unlike `default`, which is read once at first paint, this keeps arriving: set it and the tablist follows the server on every round trip — a tab restored from a URL, a validation error whose field lives in another panel, a permission that just changed. Leave it unset and the rendered output is unchanged. |
| `variant` | `string` | `'underline'` | Visual style: `'underline'`, `'pills'`, `'bordered'` |
| `orientation` | `string` | `'horizontal'` | `'horizontal'` (row) or `'vertical'` (column beside the panels) |
| `label` | `string` | `'Tabs'` | Accessible name for the tablist (set via `aria-label`) |
| `scope` | `string\|null` | `null` | Scoped personalization key |

### Items shapes

The `items` prop accepts both the keyed-assoc form (compact, ergonomic for static inline data) and the array-of-objects form (matches typical API responses, easier to compose from a Livewire `@computed` property).

:::preview{title="Array-of-objects items shape"}
<x-wirekit::tabs
    :items="[
        ['key' => 'overview', 'label' => 'Overview'],
        ['key' => 'activity', 'label' => 'Activity'],
        ['key' => 'settings', 'label' => 'Settings'],
    ]"
    default="overview"
>
    <x-slot:overview>Overview content.</x-slot:overview>
    <x-slot:activity>Activity content.</x-slot:activity>
    <x-slot:settings>Settings content.</x-slot:settings>
</x-wirekit::tabs>
:::

Both shapes produce byte-identical rendered HTML. Pick whichever your data source already produces — the component normalizes at the template edge.

## Accessibility

- Tablist: `role="tablist"`, `aria-orientation` reflects the `orientation` prop (`horizontal` or `vertical`)
- Each tab button: `role="tab"`, `aria-selected`, `aria-controls` (linked to panel id)
- Each panel: `role="tabpanel"`, `aria-labelledby` (linked to tab id)
- Inactive panels use `hidden` attribute so screen readers skip them

## Keyboard Interaction

| Key | Action |
|-----|--------|
| `Tab` | Move focus into the tab list / tab panel |
| `ArrowLeft` / `ArrowUp` | Move to the previous tab (in the same list) |
| `ArrowRight` / `ArrowDown` | Move to the next tab |
| `Home` | Move to the first tab |
| `End` | Move to the last tab |
| `Enter` / `Space` | Activate the focused tab (when manual activation is configured; default is automatic) |

Focus moves *visually* with arrow keys (roving tabindex). The active panel is only changed when the tab is activated.

## Pitfalls

- **Don't use tabs to chunk a long form.** WCAG 3.2.1 (On Focus) suffers — fields hidden in inactive tabs are bypassed by `Tab`. Use `<x-wirekit::accordion>` or pagination for sequential forms.
- **Don't put a `<form>` inside a tab panel that submits across tabs.** Inactive panels are `hidden`; their fields don't post. Either keep the form in one panel, or hoist the `<form>` element above the tab list.

## Design Tokens

Tabs use different tokens depending on the active variant. The accent color (`--color-wk-accent`) is the primary lever for changing the active tab's appearance.

### Shared Tokens (all variants)

| Element | Token |
| --- | --- |
| Tab text (inactive) | `--color-wk-text-muted` |
| Tab text (inactive hover) | `--color-wk-text` |
| Tab font size | `--text-wk-sm` |
| Tab font weight | `--font-wk-body-weight` |
| Focus ring | `--ring-wk-width` / `--color-wk-ring` |
| Disabled opacity | `--opacity-wk-disabled` |
| Transition | `--transition-wk-duration` |
| Panel padding (top) | `--padding-wk-y-md` |

### Underline Variant

| Element | Token |
| --- | --- |
| Active tab border color | `--color-wk-accent` |
| Active tab text | `--color-wk-text` |
| Tab list bottom border | `--color-wk-border` / `--border-wk-width` |

### Pills Variant

| Element | Token |
| --- | --- |
| Track background | `--color-wk-bg-muted` |
| Track radius | `--radius-wk-lg` |
| Active pill background | `--color-wk-bg-elevated` |
| Active pill text | `--color-wk-text` |
| Active pill shadow | `--shadow-wk-sm` |
| Pill radius | `--radius-wk-md` |

### Bordered Variant

| Element | Token |
| --- | --- |
| Active tab background | `--color-wk-accent` |
| Active tab text | `--color-wk-accent-fg` |
| Border | `--color-wk-border` / `--border-wk-width` |
| Container radius | `--radius-wk-md` |

### Visual Examples

These previews show how design token overrides change the appearance of each variant:

:::preview{title="Blue Accent — Underline"}
<x-wirekit::tabs
    :items="['a' => 'Dashboard', 'b' => 'Analytics', 'c' => 'Reports']"
    default="a"
    variant="underline"
    style="--color-wk-accent: #2563eb;"
>
    <x-slot:a>Dashboard content with blue accent underline.</x-slot:a>
    <x-slot:b>Analytics content.</x-slot:b>
    <x-slot:c>Reports content.</x-slot:c>
</x-wirekit::tabs>
:::

:::preview{title="Green Accent — Bordered"}
<x-wirekit::tabs
    :items="['x' => 'Users', 'y' => 'Roles', 'z' => 'Permissions']"
    default="x"
    variant="bordered"
    style="--color-wk-accent: #15803d; --color-wk-accent-fg: #fff;"
>
    <x-slot:x>Users list with green active tab.</x-slot:x>
    <x-slot:y>Roles content.</x-slot:y>
    <x-slot:z>Permissions content.</x-slot:z>
</x-wirekit::tabs>
:::

:::preview{title="Purple Accent — Pills"}
<x-wirekit::tabs
    :items="['p' => 'Overview', 'q' => 'Settings', 'r' => 'Billing']"
    default="p"
    variant="pills"
    style="--color-wk-accent: #9333ea; --color-wk-bg-muted: #f3e8ff; --color-wk-bg-elevated: #fff;"
>
    <x-slot:p>Overview with purple-tinted pill track.</x-slot:p>
    <x-slot:q>Settings content.</x-slot:q>
    <x-slot:r>Billing content.</x-slot:r>
</x-wirekit::tabs>
:::

## Customization

### Changing the Active Tab Color

The active tab color is controlled by the `--color-wk-accent` design token. Override it globally in your app's CSS to change the color of all WireKit accent elements (tabs, checkboxes, switches, etc.):

```css
@theme {
    --color-wk-accent: oklch(0.55 0.2 250); /* blue accent */
    --color-wk-accent-fg: #fff;              /* text on accent */
}
```

To change the color for **only tabs**, scope the override:

```css
/* Scope to tabs only — override the accent token on the tablist */
[role="tablist"] {
    --color-wk-accent: oklch(0.55 0.2 250);
}
```

### Config Defaults

The defaults live in `config/wirekit.php` under `components.tabs`. Override them globally:

```php
'components' => [
    'tabs' => ['variant' => 'pills'], // change default variant
],
```

### Scoped Personalization

Apply custom classes to specific tab instances using the `scope` prop:

```blade
<x-wirekit::tabs scope="settings" :items="['a' => 'Tab A']" />
```

```php
// config/wirekit.php
'personalizations' => [
    'tabs' => [
        'settings' => [
            'tablist' => 'bg-blue-50',   // custom tablist classes
            'tab' => '',                  // custom tab button classes
            'panel' => 'p-6',            // custom panel classes
        ],
    ],
],
```

## Usage & Conventions

> **Prop conventions** — this component uses one or more of the shared semantic prop names (`intent` / `variant` / `tone` / `surface`). See [Prop naming conventions](/extending/prop-naming-conventions) for the canonical vocabulary, alias matrix, and decision tree.

## Further Reading

- [WAI-ARIA Tabs Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/) — the authoring pattern this component implements
- [MDN: `role="tablist"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role)
- [MDN: `role="tab"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tab_role)
- [MDN: `role="tabpanel"`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tabpanel_role)
- [Roving tabindex pattern](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#kbd_roving_tabindex) — the keyboard navigation strategy used here
