Tabs
Tabbed navigation — a compound panel-switching primitive, or a standalone bar whose active marker slides between 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
<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
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:
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.
-
resources/views/components/ui/photo-tab.blade.php -
resources/js/ui/photo-tab.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: 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-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="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.
<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.
@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>
/**
* 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