Skip to main content
WireKit
Copy for LLM

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 with Named Slots
General settings for your account.
Security and password options.
Manage your notification preferences.

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)

Underline Variant
Overview content.
Activity content.

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.

Underline tabs — click any tab to move the active indicator
Profile section — name, avatar, locale, timezone.
Workspace section — team members, invitation link, default project.
Integrations section — connected providers, webhook endpoints, API keys.
Billing section — current plan, payment method, invoices, seat usage.

Pills

Pills Variant
Overview content.
Activity content.

Bordered

Bordered Variant
Overview content.
Activity content.

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).

Tabs with icons and a count badge
You have 8 unread messages.
Sent messages appear here.
Archived messages appear here.

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).

Vertical tabs
Manage your public profile.
Update your payment method.
Invite and manage teammates.

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:

  • 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.
{{-- 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 eventwirekit: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:

A tab bar the server drives

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).

Array-of-objects items shape
Overview content.
Activity content.
Settings content.

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:

Blue Accent — Underline
Dashboard content with blue accent underline.
Analytics content.
Reports content.
Green Accent — Bordered
Users list with green active tab.
Roles content.
Permissions content.
Purple Accent — Pills
Overview with purple-tinted pill track.
Settings content.
Billing content.

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

Was this page helpful?

Voting requires cookies or local storage. What we store