Tabs
The <x-wirekit:: 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).
A badge is neutral by default, which suits a count. When the number asks the reader to do
something, a count of errors in a panel they cannot see, give it a tone with badgeIntent, one
of the badge's own intents:
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="active 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:: 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.
Content already in the browser, switching instantly — use <x-wirekit::. Content the server renders per selection — use <x-wirekit:: with <x-wirekit::. Both bars are styled from the same source, so variant and orientation mean exactly the same thing in either.
Find in Page
Set findable and the browser's own search looks inside inactive panels too. When a match lands in one, its tab becomes the active tab, with the same selection state and keyboard focus order as a click would give it, and the browser scrolls to the text and highlights it.
It is off by default. An inactive tab is often a view of its own, and a search that switches the reader to a different view without asking can be more surprising than helpful. Turn it on where the tabs split one body of content into sections, such as settings or documentation:
<x-wirekit::tabs findable :items="['general' => 'General', 'billing' => 'Billing']">
<x-slot:general>Workspace name, time zone and language.</x-slot:general>
<x-slot:billing>Payment method, invoices and the plan you are on.</x-slot:billing>
</x-wirekit::tabs>
An inactive panel stays out of sight, out of the tab order and hidden from screen readers until its tab is active. This is a progressive enhancement: it works in browsers that support hidden="until-found", and in the rest the tabs behave exactly as without findable.
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), badge (trailing count/chip) and badgeIntent (the badge's tone, one of the badge's intents). 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) |
findable |
bool |
false |
Let the browser's find in page search inactive panels and switch to the tab a match lands in. See Find in Page |
label |
string |
'Tabs' |
Accessible name for the tablist (set via aria-label). The translated default keeps the tablist from being nameless, but every tablist on a page shares it, so two or three of them would all announce as "Tabs". Give each one the name of what its tabs switch between |
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.
tabs.list props
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string|null |
null |
Accessible name for the tablist. Required whenever a page carries more than one set of tabs — without it a screen-reader user hears "tab list" twice with nothing to tell them apart. |
variant |
string |
'underline' |
underline, pills or bordered. Inherited by the tabs inside it; see Visual Examples. |
orientation |
string |
'horizontal' |
horizontal or vertical. Vertical draws on the inline axis, so it mirrors under RTL. |
scope |
string|null |
null |
Scoped personalization name |
tabs.tab props
Only relevant when you compose the tablist by hand rather than passing items.
| Prop | Type | Default | Description |
|---|---|---|---|
selected |
bool | false |
Whether this tab is the active one |
icon |
string|null |
null |
Leading glyph |
badge |
string|int|null |
null |
Trailing count or chip |
badgeIntent |
string|null |
null |
The badge's tone, one of the badge's intents: danger for a count of errors in the panel. null keeps the badge's default |
disabled |
bool | false |
Marks the tab unavailable. It keeps aria-disabled="true" and stays reachable by arrow key — see the note below — and the component strips your activation handlers (wire:click, x-on:click, onclick and the key/mouse equivalents) so it cannot fire. Everything else you pass stays on it. |
scope |
string|null |
null |
Scoped personalization key |
A disabled tab stays reachable, deliberately. Native disabled would take it out of the page's tab order and out of the arrow cycle, leaving a keyboard reader with no way to learn the tab exists — the WAI-ARIA APG names tab elements in a tablist among the controls that should stay focusable while disabled, exactly so the reader can discover that it is there and unavailable.
That trade costs the component the suppression, because aria-disabled suppresses nothing on its own. It removes your activation handlers rather than relying on preventDefault, which cannot win against a Livewire listener registered on the same element.
Accessibility
- An
aria-labeloraria-labelledbyof yours names the tab list in place oflabel - Tablist:
role="tablist",aria-orientationreflects theorientationprop (horizontalorvertical) - Each tab button:
role="tab",aria-selected,aria-controls(linked to panel id) - Under forced colors the chosen tab is framed in the system's selection color, in every variant. A pill or a bordered tab is a fill that mode does not paint, and it paints the transparent underline of every other tab, so an underline alone would mark them all
- A disabled tab carries
aria-disabled="true"rather than the native attribute, so it stays in the arrow cycle and a keyboard reader can find out it exists and is unavailable (APG, Focusability of disabled controls). Its activation handlers are removed by the component, so it announces itself unavailable and behaves that way - Each panel:
role="tabpanel",aria-labelledby(linked to tab id) - Two tabsets with the same tab keys on one page get their own ids: the first keeps the ids derived from its keys, the second ends them in
-2, so everyaria-controlsnames a panel of its own tabset. Anidyou pass is used as given - Inactive panels stay in the DOM and are hidden with
display: none(Alpine'sx-show), which keeps them out of the accessibility tree and out of the tab order
Keyboard Interaction
| Key | Action |
|---|---|
Tab |
Move focus into the tab list, then on to the active panel |
ArrowLeft / ArrowRight |
Move focus to the previous / next tab — horizontal orientation |
ArrowUp / ArrowDown |
Move focus to the previous / next tab — vertical orientation |
Home |
Move focus to the first tab |
End |
Move focus to the last tab |
Enter / Space |
Activate the focused tab |
Only the axis that matches orientation is bound, which is what the WAI-ARIA Tabs pattern asks for. On a horizontal tab list ArrowUp and ArrowDown still belong to the page — taking them would stop a reader scrolling with the keyboard the moment focus landed on a tab.
Activation is manual, and there is no prop that changes it. Arrow keys move focus only, carried by a roving tabindex; the panel changes when the focused tab is activated with Enter, Space or a click. So arrowing across five tabs renders no panel the reader did not ask for.
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::or pagination for sequential forms.accordion> - Don't put a
<form>inside a tab panel that submits across tabs. An inactive panel is hidden with CSS, not removed — its fields are still part of the form and still post. A submit therefore carries values the reader never saw, and a validation error can name a field sitting in a panel they are not looking at. Either keep the form in one panel, or hoist the<form>element above the tab list so every panel submits together on purpose.
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 |
| Distance between the tab bar and the panel | --wk-tabs-panel-gap, --padding-wk-y-md while it is unset |
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.):
:root {
--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);
}
The Distance Below the Tab Bar
A horizontal tab bar stands --padding-wk-y-md above its panel. Where the panel holds cards, set --wk-tabs-panel-gap to the distance between your cards, so the first card sits as far from the bar as two cards sit from each other. Set it in your theme for every tab set, or on one tab set alone:
/* 1. Every tab set: the bar as far from its first card as the cards from each other */
:root {
--wk-tabs-panel-gap: var(--gap-wk-lg);
}
The panel block stays WireKit's, so a vertical tab set keeps its own layout, and a personalization of the block is not needed for the distance alone.
Config Defaults
The defaults live in config/ 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']" />
// In a service provider's boot() method. A closure receives the shipped classes, so the
// component keeps them; a plain string would replace them outright.
WireKit::scope('settings', [
'tabs' => ['classes' => [
// A token, not `bg-blue-50`: a fixed palette color is the same color under `.dark`.
'tablist' => fn (string $vendor): string => $vendor.' bg-[var(--color-wk-bg-subtle)]',
]],
]);
To let the browser's find in page search inactive panels in every tab group, set the default in config/:
'components' => [
'tabs' => ['findable' => true], // inactive panels become findable; a match switches to its tab
],
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