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.0.3
Stability
stable
License
MIT
Related
Tabs

Photo Tab — An image gallery driven by a row of thumbnail tabs. Selecting a thumbnail crossfades the large photo into a tabpanel with a subtle slide/scale; the active thumbnail gets a primary ring and a slight scale. Proper tablist/tab/tabpanel semantics, roving tabindex, and RTL-aware Arrow/Home/End keys.

Preview

Coastline
previews.components.photo-tab.default.blade.php Blade
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.photo-tab class="w-full max-w-md" />
</div>

Installation

terminal
php artisan ui:add photo-tab

Note

This component ships an Alpine behavior module at resources/js/ui/photo-tab.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './photo-tab.js';

Registry contract

php artisan ui:add photo-tab 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/photo-tab.blade.php
  • js resources/js/ui/photo-tab.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.

photo-tab.md
# Brok UI: Photo Tab (`photo-tab`)

An image gallery driven by a row of thumbnail tabs. Selecting a thumbnail crossfades the large photo into a tabpanel with a subtle slide/scale; the active thumbnail gets a primary ring and a slight scale. Proper tablist/tab/tabpanel semantics, roving tabindex, and RTL-aware Arrow/Home/End keys.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add photo-tab
```

## Usage

```blade
<div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
    <x-ui.photo-tab class="w-full max-w-md" />
</div>
```

## Props

- `photos` (array, default `[…]`) — Array of photos, each with a thumbnail and full-size image source.

## Use when

- Use for a few peer sections with relatively long content and likely back-and-forth switching.
- Presenting a small photo gallery where a row of thumbnails switches a single large preview image.
- Wanting proper tab semantics and keyboard navigation instead of a hand-rolled thumbnail-click gallery.

## Avoid when

- Do not use for many short sections where accordions or a list would fit better.
- The gallery has many photos better suited to a scrolling carousel or a grid; use a carousel component instead.
- The images should compare directly, not swap in a single frame; use compare-slider.

## Anti-patterns

- Using tabs for sequential progress
- Creating too many tabs to scan

## Rules

- Use the `<brok:photo-tab>` tag (or `<x-ui.photo-tab>`) 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/photo-tab
- Registry JSON (files, props, contract): https://brokui.dev/r/open/photo-tab.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
photos array […] Array of photos, each with a thumbnail and full-size image source.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

photo-tab

Behavior

  • Selecting a thumbnail crossfades the large photo into view with a subtle slide/scale transition.
  • The active thumbnail gets a primary ring and a slight scale to show the current selection.
  • 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.
  • Presenting a small photo gallery where a row of thumbnails switches a single large preview image.
  • Wanting proper tab semantics and keyboard navigation instead of a hand-rolled thumbnail-click gallery.

Avoid when

  • Do not use for many short sections where accordions or a list would fit better.
  • The gallery has many photos better suited to a scrolling carousel or a grid; use a carousel component instead.
  • The images should compare directly, not swap in a single frame; use compare-slider.

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
Theming hooks
photo-tab

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Home End
Focus
managed
  • Uses proper tablist/tab/tabpanel roles with roving tabindex, so only the active thumbnail is a tab stop and arrow keys move between the rest.
  • Arrow key direction is RTL-aware, and Home/End jump to the first and last thumbnail.
  • 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="photo-tab-{{ $record->id }}">
    <div class="flex min-h-40 w-full items-center justify-center rounded-lg border border-border bg-card p-8">
        <x-ui.photo-tab class="w-full max-w-md" />
    </div>
</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:photo-tab
        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/photo-tab.blade.php Blade
@props([
    // Ordered list of photos: each ['src' => '…', 'label' => '…'].
    'photos' => [
        ['src' => 'https://picsum.photos/seed/photo-tab-1/960/540', 'label' => 'Coastline'],
        ['src' => 'https://picsum.photos/seed/photo-tab-2/960/540', 'label' => 'Forest'],
        ['src' => 'https://picsum.photos/seed/photo-tab-3/960/540', 'label' => 'Mountains'],
        ['src' => 'https://picsum.photos/seed/photo-tab-4/960/540', 'label' => 'Desert'],
    ],
    // Initially selected photo index.
    'selected' => 0,
    // Accessible label for the thumbnail tablist.
    'label' => 'Photo gallery',
])

@php
    $photos = array_values(array_filter($photos, fn ($p) => is_array($p) && ! empty($p['src'])));
    if (empty($photos)) {
        $photos = [['src' => 'https://picsum.photos/seed/photo-tab-1/960/540', 'label' => 'Photo']];
    }
    $count = count($photos);
    $selected = max(0, min((int) $selected, $count - 1));

    // Unique base so multiple instances on one page keep distinct ids.
    $uid = 'photo-tab-' . str()->random(6);
@endphp

{{-- Photo Tab: a thumbnail-driven image gallery. The large image is the
     tabpanel; the thumbnail row is the tablist with roving tabindex. The
     behaviour layer only tracks the active index — crossfade/slide is x-show +
     CSS transitions (motion-safe). --}}
<div
    data-slot="photo-tab"
    x-data="uiPhotoTab({ selected: {{ $selected }}, count: {{ $count }} })"
    x-ref="root"
    {{ $attributes->merge(['class' => 'flex flex-col gap-3']) }}
>
    {{-- Large image stage: one tabpanel per photo, crossfaded. --}}
    <div class="relative aspect-video w-full overflow-hidden rounded-lg border border-border bg-muted">
        @foreach ($photos as $i => $photo)
            <div
                id="{{ $uid }}-panel-{{ $i }}"
                role="tabpanel"
                aria-labelledby="{{ $uid }}-tab-{{ $i }}"
                x-show="active === {{ $i }}"
                x-transition:enter="motion-safe:transition motion-safe:duration-500 motion-safe:ease-out"
                x-transition:enter-start="opacity-0 motion-safe:scale-[1.03] motion-safe:translate-x-2 rtl:motion-safe:-translate-x-2"
                x-transition:enter-end="opacity-100 scale-100 translate-x-0"
                x-transition:leave="motion-safe:transition motion-safe:duration-200 motion-safe:ease-in"
                x-transition:leave-start="opacity-100"
                x-transition:leave-end="opacity-0"
                class="absolute inset-0"
                @if ($i !== $selected) style="display: none" @endif
            >
                <img
                    src="{{ $photo['src'] }}"
                    alt="{{ __($photo['label'] ?? 'Photo') }}"
                    loading="lazy"
                    decoding="async"
                    draggable="false"
                    class="h-full w-full object-cover"
                />
            </div>
        @endforeach
    </div>

    {{-- Thumbnail tablist. --}}
    <div
        role="tablist"
        aria-label="{{ __($label) }}"
        aria-orientation="horizontal"
        @keydown="onKey($event)"
        class="flex items-center gap-2 overflow-x-auto pb-1"
    >
        @foreach ($photos as $i => $photo)
            <button
                type="button"
                id="{{ $uid }}-tab-{{ $i }}"
                role="tab"
                data-thumb
                data-index="{{ $i }}"
                aria-controls="{{ $uid }}-panel-{{ $i }}"
                :aria-selected="active === {{ $i }} ? 'true' : 'false'"
                :tabindex="active === {{ $i }} ? 0 : -1"
                @click="select({{ $i }})"
                class="relative size-14 shrink-0 overflow-hidden rounded-lg outline-none transition-transform duration-200 ease-out focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-selected:scale-105 aria-selected:ring-2 aria-selected:ring-primary motion-reduce:transition-none motion-reduce:aria-selected:scale-100"
            >
                <img
                    src="{{ $photo['src'] }}"
                    alt=""
                    aria-hidden="true"
                    loading="lazy"
                    decoding="async"
                    draggable="false"
                    class="h-full w-full object-cover"
                />
                <span class="sr-only">{{ __($photo['label'] ?? 'Photo') }}</span>
            </button>
        @endforeach
    </div>
</div>
resources/js/ui/photo-tab.js JS
/**
 * Photo Tab behaviour — a thumbnail-driven image gallery.
 *
 * Holds a single `active` index. A horizontal row of thumbnail buttons
 * (role="tab") drives a stack of large images (role="tabpanel"); the panels are
 * crossfaded with a slight slide/scale entirely in Blade via x-show + CSS
 * transitions, so this layer only owns selection + keyboard navigation.
 *
 * a11y: roving tabindex across the role="tab" thumbnails; the active thumbnail
 * carries aria-selected and gets focus on select. ArrowLeft/ArrowRight move
 * between thumbnails (RTL-aware — direction is read from the live computed
 * style so it always matches the rendered order), Home/End jump to the ends,
 * and selection wraps. Selecting dispatches a `change` event ({ index }) so a
 * consumer can react.
 *
 * No window/document listeners or timers are added, so there is nothing to leak;
 * destroy() is provided for symmetry and future-proofing.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 *
 * Note: this file is plain JS served by the registry — no Blade syntax allowed.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiPhotoTab', (config = {}) => ({
        count: Math.max(1, config.count ?? 1),
        active: Number.isInteger(config.selected) ? config.selected : 0,

        init() {
            this.active = Math.min(Math.max(0, this.active), this.count - 1);
        },

        destroy() {
            // No external listeners/timers were attached; nothing to tear down.
        },

        thumbs() {
            return Array.from(this.$refs.root.querySelectorAll('[data-thumb]'));
        },

        get isRtl() {
            return getComputedStyle(this.$el).direction === 'rtl';
        },

        select(index) {
            this.active = Math.min(Math.max(0, index), this.count - 1);
            const btn = this.thumbs()[this.active];
            if (btn) btn.focus();
            this.$dispatch('change', { index: this.active });
        },

        onKey(event) {
            const last = this.count - 1;
            const forward = this.isRtl ? 'ArrowLeft' : 'ArrowRight';
            const backward = this.isRtl ? 'ArrowRight' : 'ArrowLeft';
            let next = null;
            if (event.key === forward) next = this.active >= last ? 0 : this.active + 1;
            else if (event.key === backward) next = this.active <= 0 ? last : this.active - 1;
            else if (event.key === 'Home') next = 0;
            else if (event.key === 'End') next = last;
            if (next === null) return;
            event.preventDefault();
            this.select(next);
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.3
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