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.
Usage
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:
<x-wirekit::tabs class="max-w-2xl" :items="['Tab 1', 'Tab 2']">…</x-wirekit::tabs>
Variants
Underline (default)
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.
Pills
Bordered
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).
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).
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).
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:
<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:
defaultis the tab to open with. The reader takes it from there, and the server is not consulted again.activeis 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.
{{-- 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:
{{-- 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>
// 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:
Whatever your Livewire component rendered for the current tab goes here — the bar does not wrap it, hide it, or know about it.
In your component, wire each tab to the action that changes the selection:
{{-- 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.
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).
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-orientationreflects theorientationprop (horizontalorvertical) - 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
hiddenattribute 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 arehidden; 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:
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.):
@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:
/* 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:
'components' => [
'tabs' => ['variant' => 'pills'], // change default variant
],
Scoped Personalization
Apply custom classes to specific tab instances using the scope prop:
<x-wirekit::tabs scope="settings" :items="['a' => 'Tab A']" />
// 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 for the canonical vocabulary, alias matrix, and decision tree.
Further Reading
- WAI-ARIA Tabs Pattern — the authoring pattern this component implements
- MDN:
role="tablist" - MDN:
role="tab" - MDN:
role="tabpanel" - Roving tabindex pattern — the keyboard navigation strategy used here