Tabs
Tabbed navigation — a compound panel-switching primitive, or a standalone bar whose active marker slides between tabs.
Tabs — Accessible tabbed interface (wire:model/x-model bind the active value through x-modelable) with roving tabindex, arrow-key navigation, touch swipe between panels, orientation, line/pills/boxed variants and full-width and disabled-tab support, with opt-in URL sync (`param`) so a tab is shareable as a link.
Preview
Make changes to your account here. Use the arrow keys to move between tabs.
Change your password here. Focus follows the selected tab automatically.
<x-ui.tabs default="account" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="account">{{ __('Account') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="password">{{ __('Password') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="account">
<p class="text-muted-foreground">{{ __('Make changes to your account here. Use the arrow keys to move between tabs.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="password">
<p class="text-muted-foreground">{{ __('Change your password here. Focus follows the selected tab automatically.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
Installation
php artisan ui:add tabs
Note
This component ships an Alpine behavior module at
resources/js/ui/tabs.js. Import it once from your bundle so it registers on alpine:init:
import './tabs.js';
Registry contract
php artisan ui:add tabs
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/tabs.blade.php -
resources/views/components/ui/tabs/list.blade.php -
resources/views/components/ui/tabs/trigger.blade.php -
resources/views/components/ui/tabs/content.blade.php -
resources/js/ui/tabs.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Tabs (`tabs`)
Accessible tabbed interface (wire:model/x-model bind the active value through x-modelable) with roving tabindex, arrow-key navigation, touch swipe between panels, orientation, line/pills/boxed variants and full-width and disabled-tab support, with opt-in URL sync (`param`) so a tab is shareable as a link.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add tabs
```
## Usage
```blade
<x-ui.tabs default="account" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="account">{{ __('Account') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="password">{{ __('Password') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="account">
<p class="text-muted-foreground">{{ __('Make changes to your account here. Use the arrow keys to move between tabs.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="password">
<p class="text-muted-foreground">{{ __('Change your password here. Focus follows the selected tab automatically.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
```
## Props
- `default` (mixed|null, default `null`) — Value of the tab active on mount, before any URL param or bound model overrides it.
- `orientation` (string, default `horizontal`) — Layout and arrow-key axis: horizontal or vertical.
- `variant` (pills|line|boxed, default `pills`) — Visual style of the tab list: line, pills, or boxed.
- `marker` (string, default `default`) — Active-tab indicator style: default (a raised surface or foreground line) or filled (the marker takes the primary fill and the active trigger inverts onto it).
- `fullWidth` (bool, default `false`) — Stretches triggers to fill the available width instead of sizing to content.
- `param` (mixed|null, default `null`) — Opt-in URL sync: the query key that names the active tab. The URL wins over default on load, and selections are written back with replaceState so a tab is shareable as a link.
- `disabled` (false|true, default `false`) — Documented catalog control used by the preview workbench.
- `value` (mixed, default `required`) — Declared by @props in the registry Blade source.
## Use when
- Use for a few peer sections with relatively long content and likely back-and-forth switching.
- Switching between several panels of content that share the same context and screen space.
- Needing the active tab to bind with wire:model or x-model, or to sync into the URL for a shareable link.
## Avoid when
- Do not use for many short sections where accordions or a list would fit better.
- Best for a few longer peer sections when users are likely to switch between them.
- The panels are steps in a sequence the user progresses through in order; use a stepper instead.
- The choice should navigate to a distinct page rather than swap content in place; use plain links or navigation-menu.
## Anti-patterns
- Using tabs for sequential progress
- Creating too many tabs to scan
## Rules
- Use the `<brok:tabs>` tag (or `<x-ui.tabs>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/tabs
- Registry JSON (files, props, contract): https://brokui.dev/r/open/tabs.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
Examples
<x-ui.tabs default="general" variant="boxed" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="general">{{ __('General') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="billing">{{ __('Billing') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="team">{{ __('Team') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="general">
<p class="text-muted-foreground">{{ __('Enclosed tabs read as a connected strip above the panel.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="billing">
<p class="text-muted-foreground">{{ __('Manage invoices and payment methods.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="team">
<p class="text-muted-foreground">{{ __('Invite and manage teammates.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
<x-ui.tabs default="details" variant="pills" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="details">{{ __('Details') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="shipping" :disabled="true">{{ __('Shipping') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="review">{{ __('Review') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="details">
<p class="text-muted-foreground">{{ __('A disabled tab is skipped by arrow-key navigation and cannot be focused.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="shipping">
<p class="text-muted-foreground">{{ __('Shipping is unavailable for this order.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="review">
<p class="text-muted-foreground">{{ __('Review and confirm.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
{{-- marker="filled": the sliding marker takes the primary fill and the active
label inverts onto it, on the pills track and as a line. --}}
<div class="flex w-full max-w-md flex-col gap-8">
<x-ui.tabs default="overview" marker="filled">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="overview">{{ __('Overview') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="activity">{{ __('Activity') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="settings">{{ __('Settings') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="overview">
<p class="text-muted-foreground">{{ __('A primary pill slides under the active tab.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="activity">
<p class="text-muted-foreground">{{ __('Recent activity appears here.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="settings">
<p class="text-muted-foreground">{{ __('Adjust your preferences here.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
<x-ui.tabs default="account" variant="line" marker="filled">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="account">{{ __('Account') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="billing">{{ __('Billing') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="team">{{ __('Team') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="account">
<p class="text-muted-foreground">{{ __('A primary underline marks the active tab.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="billing">
<p class="text-muted-foreground">{{ __('Invoices and payment methods.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="team">
<p class="text-muted-foreground">{{ __('Members and roles.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
</div>
Full Width
<x-ui.tabs default="all" variant="pills" :full-width="true" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="all">{{ __('All') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="active">{{ __('Active') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="archived">{{ __('Archived') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="all">
<p class="text-muted-foreground">{{ __('Full-width triggers share the row equally.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="active">
<p class="text-muted-foreground">{{ __('Only active items.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="archived">
<p class="text-muted-foreground">{{ __('Archived items.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
<x-ui.tabs default="overview" variant="line" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="overview">{{ __('Overview') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="activity">{{ __('Activity') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="settings">{{ __('Settings') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="overview">
<p class="text-muted-foreground">{{ __('An underline marks the active tab. Arrow keys move between tabs.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="activity">
<p class="text-muted-foreground">{{ __('Recent activity appears here.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="settings">
<p class="text-muted-foreground">{{ __('Adjust your preferences here.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
<x-ui.tabs default="profile" orientation="vertical" variant="line" class="w-full max-w-lg">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="profile">{{ __('Profile') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="account">{{ __('Account') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="notifications">{{ __('Notifications') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="profile">
<p class="text-muted-foreground">{{ __('Vertical tabs stack down the side. Up and down arrows move between them, mirrored under RTL.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="account">
<p class="text-muted-foreground">{{ __('Account details live here.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="notifications">
<p class="text-muted-foreground">{{ __('Choose which notifications you receive.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
With Badge
<x-ui.tabs default="inbox" variant="pills" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="inbox">
{{ __('Inbox') }}
<x-ui.badge variant="secondary" size="sm">12</x-ui.badge>
</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="drafts">
{{ __('Drafts') }}
<x-ui.badge variant="secondary" size="sm">3</x-ui.badge>
</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="sent">{{ __('Sent') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="inbox">
<p class="text-muted-foreground">{{ __('Count badges sit after the label and inherit the active state.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="drafts">
<p class="text-muted-foreground">{{ __('Unsent drafts.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="sent">
<p class="text-muted-foreground">{{ __('Messages you have sent.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
With Icons
<x-ui.tabs default="home" variant="line" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="home">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</svg>
{{ __('Home') }}
</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="reports">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4 shrink-0">
<path d="M3 3v18h18" />
<path d="m19 9-5 5-4-4-3 3" />
</svg>
{{ __('Reports') }}
</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="home">
<p class="text-muted-foreground">{{ __('Icons sit before the label and inherit the tab text colour.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="reports">
<p class="text-muted-foreground">{{ __('Your reports live here.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| default | mixed | null | null | Value of the tab active on mount, before any URL param or bound model overrides it. |
| orientation | string | horizontal | Layout and arrow-key axis: horizontal or vertical. |
| variant | pills | line | boxed | pills | Visual style of the tab list: line, pills, or boxed. |
| marker | string | default | Active-tab indicator style: default (a raised surface or foreground line) or filled (the marker takes the primary fill and the active trigger inverts onto it). |
| fullWidth | bool | false | Stretches triggers to fill the available width instead of sizing to content. |
| param | mixed | null | null | Opt-in URL sync: the query key that names the active tab. The URL wins over default on load, and selections are written back with replaceState so a tab is shareable as a link. |
| disabled | false | true | false | Documented catalog control used by the preview workbench. |
| value | mixed | required | Declared by @props in the registry Blade source. |
Slots
default— Compositional parts: list (with triggers) and content panels.x-ui.tabs.list— Installed subcomponent from the registry item.x-ui.tabs.trigger— Installed subcomponent from the registry item.x-ui.tabs.content— Installed subcomponent from the registry item.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- The active tab binds through x-modelable, so wire:model (Livewire) or x-model (Alpine) can control it from outside the component.
- Arrow keys move between triggers with roving tabindex, and on touch devices swiping between panels also changes the active tab.
- With param set, the active tab is read from and written back to the URL query string, so a specific tab is shareable as a link.
- Binds wire:model (Livewire) or x-model (Alpine) to the active value through x-modelable.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Switch between a few substantial peer sections.
Use when
- Use for a few peer sections with relatively long content and likely back-and-forth switching.
- Switching between several panels of content that share the same context and screen space.
- Needing the active tab to bind with wire:model or x-model, or to sync into the URL for a shareable link.
Avoid when
- Do not use for many short sections where accordions or a list would fit better.
- Best for a few longer peer sections when users are likely to switch between them.
- The panels are steps in a sequence the user progresses through in order; use a stepper instead.
- The choice should navigate to a distinct page rather than swap content in place; use plain links or navigation-menu.
Use instead
- Accordion for many short secondary sections
- Dedicated pages for distinct destinations
Anti-patterns
- Using tabs for sequential progress
- Creating too many tabs to scan
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- Home End
- Focus
managed
- Follows the WAI-ARIA tabs pattern with roving tabindex, so only the active trigger is a tab stop and arrow keys move between the rest.
- Orientation is reflected in aria-orientation, so assistive tech announces vertical tabs correctly rather than assuming horizontal.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="tabs-{{ $record->id }}">
<x-ui.tabs default="account" class="w-full max-w-md">
<x-ui.tabs.list>
<x-ui.tabs.trigger value="account">{{ __('Account') }}</x-ui.tabs.trigger>
<x-ui.tabs.trigger value="password">{{ __('Password') }}</x-ui.tabs.trigger>
</x-ui.tabs.list>
<x-ui.tabs.content value="account">
<p class="text-muted-foreground">{{ __('Make changes to your account here. Use the arrow keys to move between tabs.') }}</p>
</x-ui.tabs.content>
<x-ui.tabs.content value="password">
<p class="text-muted-foreground">{{ __('Change your password here. Focus follows the selected tab automatically.') }}</p>
</x-ui.tabs.content>
</x-ui.tabs>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:tabs
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'default' => null,
'orientation' => 'horizontal',
'variant' => 'pills',
// default: the marker is a raised surface (pills/boxed) or a foreground
// line. filled: the marker takes the primary fill and the active trigger
// inverts onto it — the former animated-tabs look.
'marker' => 'default',
'fullWidth' => false,
// Opt-in URL sync: the query key that names the active tab (`?tab=…`).
// The URL wins over `default` on load; selections are written back with
// replaceState so a record tab is shareable as a link.
'param' => null,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$marker = $marker === 'filled' ? 'filled' : 'default';
// animated-tabs renders this component under its own name; the browser
// keeps only the first of two identical attributes, so read the override
// out of the bag and print exactly one.
$slotName = (string) ($attributes->get('data-slot') ?: 'tabs');
@endphp
{{--
Tabs (spec §17 keyboard + §19 behavior layer).
Active tab lives on the root. Triggers use roving tabindex with arrow-key
navigation; panels are shown/hidden by value.
Visual options (behaviour unchanged — keyboard/aria identical across them):
- variant: pills (default, segmented track) | line (underline bar) | boxed (enclosed tab strip)
- marker: default (raised surface / foreground line) | filled (primary fill, inverted active label)
- orientation: horizontal (default) | vertical (arrow keys follow axis, RTL-aware)
- fullWidth: stretch triggers to share the row equally (horizontal only)
- param: query key to read/write the active tab from/to the URL (deep-linkable tabs)
Children read variant/orientation/fullWidth through @aware so the list and
triggers style themselves consistently without prop drilling.
--}}
<div
x-data="uiTabs({ default: @js($default), orientation: @js($orientation), param: @js($param) })"
x-modelable="active"
x-id="['tabs-trigger', 'tabs-panel']"
data-slot="{{ $slotName }}"
data-variant="{{ $variant }}"
data-marker="{{ $marker }}"
:data-orientation="orientation"
{{ $attributes->except('data-slot')->merge(['class' => 'flex flex-col gap-4 data-[orientation=vertical]:flex-row']) }}
>
{{ $slot }}
</div>
@aware([
'variant' => 'pills',
'marker' => 'default',
'fullWidth' => false,
'orientation' => 'horizontal',
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$recipe = $styles['tabs'] ?? [];
$listMap = $recipe['list'] ?? [];
$listClass = ($recipe['listBase'] ?? '').' '.($listMap[$variant] ?? $listMap['pills'] ?? '');
// fullWidth stretches each trigger to share the row; only meaningful on the
// horizontal axis (vertical lists already stretch via data-orientation).
$fullWidthClass = $fullWidth ? ($recipe['listFull'] ?? '') : '';
// Sliding active marker; the vertical line variant swaps to a block-axis
// bar, and marker="filled" swaps the surface for the primary fill.
$filled = $marker === 'filled';
$markerMap = ($orientation === 'vertical' ? ($recipe[$filled ? 'markerFilledVertical' : 'markerVertical'] ?? []) : [])
+ ($recipe[$filled ? 'markerFilled' : 'marker'] ?? []);
$markerClass = $markerMap[$variant] ?? $markerMap['pills'] ?? '';
@endphp
<div
data-slot="tabs-list"
data-variant="{{ $variant }}"
role="tablist"
:aria-orientation="orientation"
@keydown="onKey($event)"
:data-orientation="orientation"
{{ $attributes->merge(['class' => trim($listClass).' '.$fullWidthClass]) }}
>
<span
x-ref="marker"
data-slot="tabs-marker"
data-variant="{{ $variant }}"
aria-hidden="true"
class="{{ $markerClass }}"
></span>
{{ $slot }}
</div>
@aware([
'variant' => 'pills',
'marker' => 'default',
'fullWidth' => false,
])
@props([
'value',
'disabled' => false,
])
@php
$styles = require base_path(config('ui.component_path', 'resources/views/components/ui').'/_styles.php');
$variant = $styles['normalizeVariant']($variant);
$recipe = $styles['tabs'] ?? [];
$triggerMap = $recipe['trigger'] ?? [];
$triggerClass = ($recipe['triggerBase'] ?? '').' '.($triggerMap[$variant] ?? $triggerMap['pills'] ?? '');
// marker="filled": the active label inverts onto the primary marker.
if ($marker === 'filled') {
$triggerClass .= ' '.(($recipe['triggerFilled'] ?? [])[$variant] ?? '');
}
// fullWidth lets each trigger flex-grow to share the row equally.
$fullWidthClass = $fullWidth ? ($recipe['triggerFull'] ?? '') : '';
@endphp
<button
type="button"
data-slot="tabs-trigger"
data-variant="{{ $variant }}"
role="tab"
data-value="{{ $value }}"
x-init="register(@js($value), {{ $disabled ? 'true' : 'false' }})"
:id="$id('tabs-trigger', @js($value))"
:aria-selected="isActive(@js($value))"
:tabindex="isActive(@js($value)) ? 0 : -1"
:data-state="isActive(@js($value)) ? 'active' : 'inactive'"
x-bind:aria-controls="$id('tabs-panel', @js($value))"
@click="select(@js($value))"
@disabled($disabled)
@if ($disabled) aria-disabled="true" @endif
{{ $attributes->merge(['class' => trim($triggerClass).' '.$fullWidthClass]) }}
>
{{ $slot }}
</button>
@props([
'value',
])
<div
data-slot="tabs-content"
role="tabpanel"
tabindex="0"
x-show="isActive(@js($value))"
x-cloak
:id="$id('tabs-panel', @js($value))"
x-bind:aria-labelledby="$id('tabs-trigger', @js($value))"
:data-state="isActive(@js($value)) ? 'active' : 'inactive'"
{{-- Enter-only fade: rapid tab switching must never queue exit animations. --}}
x-transition:enter="motion-safe:transition-opacity motion-safe:duration-fast motion-safe:ease-standard"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
{{ $attributes->merge(['class' => 'text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none']) }}
>
{{ $slot }}
</div>
/**
* Tabs behavior (spec §17 keyboard navigation + §19 behavior layer).
*
* Triggers register themselves so the first one can become active by default
* and arrow-key navigation knows the order. Navigation respects orientation
* (ArrowLeft/Right for horizontal, ArrowUp/Down for vertical) and is RTL-aware.
*
* Touch swipe: a horizontal touch drag that begins inside a tab panel
* (data-slot="tabs-content") past a small threshold advances to the next/prev
* enabled tab (clamped at the ends, RTL-aware, horizontal orientation only). It
* is gated to `pointerType === 'touch'` so mouse/keyboard interaction and any
* horizontal scrolling inside panel content are left untouched. Listeners are
* added in init() and removed in destroy() so unmounting can't leak.
*
* URL sync (`param`): a record page with several tabs is shared as a link
* to one of them. When `param` names a query key, the tab named in the URL
* on load wins over `default` (only if a trigger with that value exists —
* an unknown value is ignored, never trusted), and every later selection is
* written back with `history.replaceState` so the address bar follows the
* operator without growing the back stack. Off unless asked for.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiTabs', (config = {}) => ({
active: config.default ?? null,
orientation: config.orientation ?? 'horizontal',
param: typeof config.param === 'string' && config.param !== '' ? config.param : null,
values: [],
// Touch-swipe bookkeeping.
_swipeId: null,
_swipeX: 0,
_swipeY: 0,
_onPointerDown: null,
_onPointerUp: null,
// Sliding-marker bookkeeping.
_markerObserver: null,
_markerPlaced: false,
init() {
this._bindSwipe();
this._readParam();
if (this.param !== null) {
this.$watch('active', () => this._writeParam());
}
// Keep the active marker under the active trigger: on selection
// change (after the DOM updates) and on any size change.
this.$watch('active', () => this.$nextTick(() => this.syncMarker()));
this.$nextTick(() => this.syncMarker());
if (window.ResizeObserver) {
this._markerObserver = new ResizeObserver(() => this.syncMarker());
this._markerObserver.observe(this.$el);
}
},
register(value, disabled = false) {
if (!this.values.includes(value)) {
this.values.push(value);
}
// A tab named in the URL claims the initial selection as soon as
// its trigger registers; `default` and first-enabled only apply
// when the URL names nothing (or nothing that exists).
if (this._requested !== null && this._requested === value && !disabled) {
this.active = value;
this._requested = null;
return;
}
// Don't let a disabled trigger become the default active tab; the
// first enabled trigger claims it instead.
if (this.active === null && !disabled) {
this.active = value;
}
},
// --- URL sync (opt-in via `param`) ------------------------------------
_requested: null,
_readParam() {
if (this.param === null || typeof window === 'undefined') return;
try {
const value = new URLSearchParams(window.location.search).get(this.param);
if (value !== null && value !== '') {
this._requested = value;
}
} catch {
this._requested = null;
}
},
_writeParam() {
if (this.param === null || typeof window === 'undefined' || this.active === null) return;
try {
const url = new URL(window.location.href);
if (url.searchParams.get(this.param) === String(this.active)) return;
url.searchParams.set(this.param, String(this.active));
window.history.replaceState(window.history.state, '', url);
} catch {
/* A sandboxed or file: document may refuse replaceState; the tab still switches. */
}
},
isActive(value) {
return this.active === value;
},
select(value) {
this.active = value;
},
/**
* Move the sliding marker under the active trigger. Uses physical
* offsets (offsetLeft/offsetTop are relative to the list, which is the
* offset parent), so it is RTL-correct without logical mapping. The
* first placement snaps instead of sliding in from 0,0.
*/
syncMarker() {
const marker = this.$refs.marker;
if (!marker) {
return;
}
const active =
this.active === null
? null
: this.$el.querySelector(
`[data-slot="tabs-trigger"][data-value="${CSS.escape(String(this.active))}"]`
);
if (!active) {
marker.style.opacity = '0';
return;
}
const firstPlacement = !this._markerPlaced;
if (firstPlacement) {
marker.style.transition = 'none';
}
const vertical = this.orientation === 'vertical';
if (marker.dataset.variant === 'line') {
// Line marker is a thin bar on the active edge; only the
// in-axis position and length track the trigger.
if (vertical) {
marker.style.transform = `translateY(${active.offsetTop}px)`;
marker.style.height = `${active.offsetHeight}px`;
} else {
marker.style.transform = `translateX(${active.offsetLeft}px)`;
marker.style.width = `${active.offsetWidth}px`;
}
} else {
marker.style.transform = `translate(${active.offsetLeft}px, ${active.offsetTop}px)`;
marker.style.width = `${active.offsetWidth}px`;
marker.style.height = `${active.offsetHeight}px`;
}
marker.style.opacity = '1';
if (firstPlacement) {
this._markerPlaced = true;
void marker.offsetWidth;
marker.style.transition = '';
}
},
onKey(event) {
const triggers = Array.from(
this.$el.querySelectorAll('[data-slot="tabs-trigger"]:not(:disabled)')
);
const current = triggers.indexOf(event.target);
if (current === -1) {
return;
}
const rtl = getComputedStyle(this.$el).direction === 'rtl';
const horizontal = this.orientation !== 'vertical';
const nextKey = horizontal ? (rtl ? 'ArrowLeft' : 'ArrowRight') : 'ArrowDown';
const prevKey = horizontal ? (rtl ? 'ArrowRight' : 'ArrowLeft') : 'ArrowUp';
let next;
if (event.key === nextKey) {
next = (current + 1) % triggers.length;
} else if (event.key === prevKey) {
next = (current - 1 + triggers.length) % triggers.length;
} else if (event.key === 'Home') {
next = 0;
} else if (event.key === 'End') {
next = triggers.length - 1;
} else {
return;
}
event.preventDefault();
const target = triggers[next];
target.focus();
this.select(target.getAttribute('data-value'));
},
// --- Touch swipe between panels (additive, touch-only) ----------------
// Advance to the next/previous enabled tab, clamped at the ends. Returns
// without changing state if there is no enabled neighbour in that
// direction, so existing selection state and events are respected.
_swipeTo(forward) {
const triggers = Array.from(
this.$el.querySelectorAll('[data-slot="tabs-trigger"]:not(:disabled)')
);
const values = triggers.map((t) => t.getAttribute('data-value'));
const current = values.indexOf(this.active);
if (current === -1) return;
const next = forward ? current + 1 : current - 1;
if (next < 0 || next > values.length - 1) return; // clamp at ends
this.select(values[next]);
},
_bindSwipe() {
const root = this.$el;
if (!root) return;
this._onPointerDown = (event) => {
if (event.pointerType !== 'touch') return;
// Only react to swipes that begin inside a tab panel, not the
// trigger row (which has its own gestures/focus behaviour).
if (!event.target.closest('[data-slot="tabs-content"]')) return;
this._swipeId = event.pointerId;
this._swipeX = event.clientX;
this._swipeY = event.clientY;
};
this._onPointerUp = (event) => {
if (this._swipeId === null || event.pointerId !== this._swipeId) return;
const dx = event.clientX - this._swipeX;
const dy = event.clientY - this._swipeY;
this._swipeId = null;
// Swipe only drives the horizontal tab axis.
if (this.orientation === 'vertical') return;
const threshold = 40;
// Ignore mostly-vertical drags so content scroll isn't hijacked.
if (Math.abs(dx) < threshold || Math.abs(dx) <= Math.abs(dy)) return;
const rtl = getComputedStyle(root).direction === 'rtl';
// Swipe left = advance (next); inverted under RTL.
const forward = rtl ? dx > 0 : dx < 0;
this._swipeTo(forward);
};
root.addEventListener('pointerdown', this._onPointerDown);
root.addEventListener('pointerup', this._onPointerUp);
},
destroy() {
const root = this.$el;
if (root && this._onPointerDown) {
root.removeEventListener('pointerdown', this._onPointerDown);
root.removeEventListener('pointerup', this._onPointerUp);
}
this._onPointerDown = null;
this._onPointerUp = null;
if (this._markerObserver) {
this._markerObserver.disconnect();
this._markerObserver = null;
}
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
1.6.5 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0