Skip to content
Brok UI

Loading…

No results

Tabs

Open source Core

Tabbed navigation — a compound panel-switching primitive, or a standalone bar whose active marker slides between tabs.

Version
v1.6.5
Stability
stable
License
MIT
Related
Photo Tab

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.

Orientation
Disabled
previews.components.tabs.default.blade.php 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>
Horizontal Current
Vertical Current

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/tabs.blade.php
  • blade resources/views/components/ui/tabs/list.blade.php
  • blade resources/views/components/ui/tabs/trigger.blade.php
  • blade resources/views/components/ui/tabs/content.blade.php
  • js resources/js/ui/tabs.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

tabs.md
# 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

boxed.blade.php Blade
<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>
disabled.blade.php Blade
<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>
filled.blade.php Blade
{{-- 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.blade.php Blade
<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>
line.blade.php Blade
<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>
vertical.blade.php Blade
<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.blade.php Blade
<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.blade.php Blade
<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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

tabs-content tabs-list tabs-marker tabs-trigger {{ $slotName }}

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

Peer-section tabs

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
root tabs-content tabs-list tabs-marker tabs-trigger
Theming hooks
tabs

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/tabs.blade.php Blade
@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>
resources/views/components/ui/tabs/list.blade.php Blade
@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>
resources/views/components/ui/tabs/trigger.blade.php Blade
@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>
resources/views/components/ui/tabs/content.blade.php Blade
@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>
resources/js/ui/tabs.js JS
/**
 * 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