Skip to content
Brok UI

Loading…

No results

Media Collections

Open source

Data-driven image sets with a zoom lightbox — a masonry or uniform tile grid, and an ordered, reorderable strip of screen-flow steps.

Version
v1.0.0
Stability
stable
License
MIT
Related
Image Viewer
Flow Strip
Image Picker
Carousel
1 of 2

Media Grid — A data-driven list of image tiles for collections (Cosmos or Mobbin style): masonry columns with intrinsic aspect or uniform cropped tiles, column count from the grid's own width, captions that link to a detail page, tile actions, and an image-viewer lightbox with zoom.

Preview

Size
previews.components.media-grid.default.blade.php Blade
<x-ui.media-grid
    label="{{ __('Saved inspiration') }}"
    :items="[
        ['id' => 'hero-mockup', 'src' => asset('thumbs/block/hero-mockup.webp'), 'alt' => __('Hero section with a product mockup beside the headline'), 'width' => 960, 'height' => 720, 'title' => __('Hero with mockup'), 'meta' => __('Marketing'), 'href' => url('/blocks/hero-mockup')],
        ['id' => 'banner-newsletter', 'src' => asset('thumbs/block/banner-newsletter.webp'), 'alt' => __('Newsletter banner with an email field'), 'width' => 960, 'height' => 320, 'title' => __('Newsletter banner'), 'meta' => __('Marketing'), 'href' => url('/blocks/banner-newsletter')],
        ['id' => 'auth-sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in'), 'meta' => __('Authentication'), 'href' => url('/blocks/auth-sign-in')],
        ['id' => 'checkout-two-column', 'src' => asset('thumbs/block/checkout-two-column.webp'), 'alt' => __('Two-column checkout with the order summary beside the form'), 'width' => 960, 'height' => 720, 'title' => __('Two-column checkout'), 'meta' => __('Commerce'), 'href' => url('/blocks/checkout-two-column')],
        ['id' => 'dashboard-widget', 'src' => asset('thumbs/block/dashboard-widget.webp'), 'alt' => __('Dashboard widget row with small charts'), 'width' => 960, 'height' => 320, 'title' => __('Dashboard widgets'), 'meta' => __('Application')],
        ['id' => 'order-summary-tracking', 'src' => asset('thumbs/block/order-summary-tracking.webp'), 'alt' => __('Order tracking summary with a delivery progress bar'), 'width' => 960, 'height' => 462, 'title' => __('Order tracking'), 'meta' => __('Commerce')],
        ['id' => 'settings-security', 'src' => asset('thumbs/block/settings-security.webp'), 'alt' => __('Security settings with password and two-factor sections'), 'width' => 960, 'height' => 720, 'title' => __('Security settings'), 'meta' => __('Application')],
        ['id' => 'features-bento-grid', 'src' => asset('thumbs/block/features-bento-grid.webp'), 'alt' => __('Feature section laid out as a bento grid'), 'width' => 960, 'height' => 720, 'title' => __('Bento features'), 'meta' => __('Marketing')],
    ]"
/>
Sm Current
Md Current
Lg Current

Installation

terminal
php artisan ui:add media-grid

Note

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

resources/js/ui/index.js JS
import './media-grid.js';

Registry contract

php artisan ui:add media-grid 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/media-grid.blade.php
  • blade resources/views/components/ui/media-grid/item.blade.php
Registry dependencies
image-viewer empty
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.

media-grid.md
# Brok UI: Media Grid (`media-grid`)

A data-driven list of image tiles for collections (Cosmos or Mobbin style): masonry columns with intrinsic aspect or uniform cropped tiles, column count from the grid's own width, captions that link to a detail page, tile actions, and an image-viewer lightbox with zoom.

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

## Install

```bash
php artisan ui:add media-grid
```

## Usage

```blade
<x-ui.media-grid
    label="{{ __('Saved inspiration') }}"
    :items="[
        ['id' => 'hero-mockup', 'src' => asset('thumbs/block/hero-mockup.webp'), 'alt' => __('Hero section with a product mockup beside the headline'), 'width' => 960, 'height' => 720, 'title' => __('Hero with mockup'), 'meta' => __('Marketing'), 'href' => url('/blocks/hero-mockup')],
        ['id' => 'banner-newsletter', 'src' => asset('thumbs/block/banner-newsletter.webp'), 'alt' => __('Newsletter banner with an email field'), 'width' => 960, 'height' => 320, 'title' => __('Newsletter banner'), 'meta' => __('Marketing'), 'href' => url('/blocks/banner-newsletter')],
        ['id' => 'auth-sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in'), 'meta' => __('Authentication'), 'href' => url('/blocks/auth-sign-in')],
        ['id' => 'checkout-two-column', 'src' => asset('thumbs/block/checkout-two-column.webp'), 'alt' => __('Two-column checkout with the order summary beside the form'), 'width' => 960, 'height' => 720, 'title' => __('Two-column checkout'), 'meta' => __('Commerce'), 'href' => url('/blocks/checkout-two-column')],
        ['id' => 'dashboard-widget', 'src' => asset('thumbs/block/dashboard-widget.webp'), 'alt' => __('Dashboard widget row with small charts'), 'width' => 960, 'height' => 320, 'title' => __('Dashboard widgets'), 'meta' => __('Application')],
        ['id' => 'order-summary-tracking', 'src' => asset('thumbs/block/order-summary-tracking.webp'), 'alt' => __('Order tracking summary with a delivery progress bar'), 'width' => 960, 'height' => 462, 'title' => __('Order tracking'), 'meta' => __('Commerce')],
        ['id' => 'settings-security', 'src' => asset('thumbs/block/settings-security.webp'), 'alt' => __('Security settings with password and two-factor sections'), 'width' => 960, 'height' => 720, 'title' => __('Security settings'), 'meta' => __('Application')],
        ['id' => 'features-bento-grid', 'src' => asset('thumbs/block/features-bento-grid.webp'), 'alt' => __('Feature section laid out as a bento grid'), 'width' => 960, 'height' => 720, 'title' => __('Bento features'), 'meta' => __('Marketing')],
    ]"
/>
```

## Props

- `items` (array, default `[]`) — Tiles as [['id', 'src', 'original'?, 'alt', 'width'?, 'height'?, 'title'?, 'meta'?, 'href'?]]. Rendered through media-grid.item. Leave empty to render your own <x-ui.media-grid.item> tiles in the default slot. Each tile needs a src and a non-empty alt, or rendering throws.
- `layout` (masonry|grid, default `masonry`) — masonry flows tiles through CSS columns at their intrinsic aspect ratio (DOM and tab order run down each column); grid uses uniform 4:3 tiles with the image cropped to cover.
- `size` (sm|md|lg, default `md`) — Tile width. The column count comes from container queries on the grid's own box, so a grid in a narrow pane shows fewer columns.
- `lightbox` (bool, default `true`) — Each image is an image-viewer trigger that opens the full-size original with zoom and pan. false renders plain lazy images.
- `label` (string|null, default `null`) — Accessible name of the list, for example "Onboarding screens".
- `emptyText` (string|null, default `null`) — Text of the empty state when there are no items and the slot is empty. Defaults to "No images yet."
- `id` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `src` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `original` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `alt` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `width` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `height` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `title` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `meta` (mixed|null, default `null`) — Declared by @props in the registry Blade source.
- `href` (mixed|null, default `null`) — Declared by @props in the registry Blade source.

## Use when

- Use when image or video selection, preview, or media-based comparison is part of the task.
- Showing a collection of images in an app (inspiration boards, screenshot libraries, uploaded media) from data, where each image can open at full size with zoom.
- A Livewire view that renders its own tiles with per-tile actions (remove, menu) and needs stable wire:key morphing.
- A grid that sits in a split pane or sidebar layout: the column count follows the grid's own width, not the viewport.

## Avoid when

- Do not require media-heavy interaction when a simpler text or list solution would be faster.
- A marketing page section with a heading and a fixed set of showcase images; use the gallery-grid or gallery-masonry block.
- Picking images from a set; use image-picker.
- One image to inspect; use image-viewer on its own.
- An ordered sequence of screens; use flow-strip.

## Anti-patterns

- Requiring media-heavy interaction when text is faster

## Rules

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

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

Examples

actions.blade.php Blade
{{-- A Livewire view renders its own tiles: each keeps wire:key="media-grid-<id>"
     and gets per-tile actions. Here the buttons only show the pattern. --}}
<x-ui.media-grid layout="grid" label="{{ __('Board: Checkout ideas') }}">
    @foreach ([
        ['id' => 'checkout', 'file' => 'checkout', 'alt' => __('Single-column checkout form'), 'title' => __('Checkout'), 'meta' => __('Saved today')],
        ['id' => 'checkout-express', 'file' => 'checkout-express', 'alt' => __('Express checkout with wallet buttons'), 'title' => __('Express checkout'), 'meta' => __('Saved yesterday')],
        ['id' => 'shopping-cart', 'file' => 'shopping-cart', 'alt' => __('Shopping cart with line items and a subtotal'), 'title' => __('Shopping cart'), 'meta' => __('Saved last week')],
    ] as $image)
        <x-ui.media-grid.item
            :id="$image['id']"
            :src="asset('thumbs/block/'.$image['file'].'.webp')"
            :alt="$image['alt']"
            :title="$image['title']"
            :meta="$image['meta']"
            :href="url('/blocks/'.$image['file'])"
        >
            <x-slot:actions>
                <x-ui.button variant="secondary" size="sm" icon-label="{{ __('Remove :title from the board', ['title' => $image['title']]) }}" data-action="remove">
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4" aria-hidden="true"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
                </x-ui.button>
            </x-slot:actions>
        </x-ui.media-grid.item>
    @endforeach
</x-ui.media-grid>
empty.blade.php Blade
<x-ui.media-grid :items="[]" label="{{ __('Saved inspiration') }}" empty-text="{{ __('No images saved yet. Capture a page with the extension to add one.') }}" />
grid.blade.php Blade
<x-ui.media-grid
    layout="grid"
    size="sm"
    label="{{ __('Authentication screens') }}"
    :items="[
        ['id' => 'auth-sign-in', 'src' => asset('thumbs/block/auth-sign-in.webp'), 'alt' => __('Sign-in form with email and password fields'), 'width' => 960, 'height' => 600, 'title' => __('Sign in')],
        ['id' => 'auth-sign-up', 'src' => asset('thumbs/block/auth-sign-up.webp'), 'alt' => __('Sign-up form with name, email and password'), 'width' => 960, 'height' => 600, 'title' => __('Sign up')],
        ['id' => 'auth-verify-email', 'src' => asset('thumbs/block/auth-verify-email.webp'), 'alt' => __('Verify email notice with a resend button'), 'width' => 960, 'height' => 600, 'title' => __('Verify email')],
        ['id' => 'auth-two-factor', 'src' => asset('thumbs/block/auth-two-factor.webp'), 'alt' => __('Two-factor code entry'), 'width' => 960, 'height' => 600, 'title' => __('Two-factor')],
        ['id' => 'accept-invite', 'src' => asset('thumbs/block/accept-invite.webp'), 'alt' => __('Team invitation with an accept button'), 'width' => 960, 'height' => 600, 'title' => __('Accept invite')],
        ['id' => 'banner-newsletter', 'src' => asset('thumbs/block/banner-newsletter.webp'), 'alt' => __('Newsletter banner with an email field'), 'width' => 960, 'height' => 320, 'title' => __('Newsletter banner')],
    ]"
/>
long-content.blade.php Blade
<div class="max-w-sm">
    <x-ui.media-grid
        label="{{ __('Screens with long names') }}"
        :items="[
            ['id' => 'order-summary-confirmation', 'src' => asset('thumbs/block/order-summary-confirmation.webp'), 'alt' => __('Order confirmation page that thanks the customer, lists every purchased item with its price and shows the delivery address'), 'width' => 960, 'height' => 600, 'title' => __('Order confirmation with an itemised receipt, the delivery address and a link back to the shop'), 'meta' => __('Captured from https://shop.example.com/orders/confirmation/2026-10-02/very-long-reference-number-0042'), 'href' => url('/blocks/order-summary-confirmation')],
            ['id' => 'checkout-express', 'src' => asset('thumbs/block/checkout-express.webp'), 'alt' => __('Express checkout with wallet buttons'), 'width' => 960, 'height' => 625, 'title' => __('Express checkout'), 'meta' => __('Commerce')],
            ['id' => 'banner-newsletter', 'src' => asset('thumbs/block/banner-newsletter.webp'), 'alt' => __('Newsletter banner with an email field'), 'width' => 960, 'height' => 320],
        ]"
    />
</div>

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
items array [] Tiles as [['id', 'src', 'original'?, 'alt', 'width'?, 'height'?, 'title'?, 'meta'?, 'href'?]]. Rendered through media-grid.item. Leave empty to render your own <x-ui.media-grid.item> tiles in the default slot. Each tile needs a src and a non-empty alt, or rendering throws.
layout masonry | grid masonry masonry flows tiles through CSS columns at their intrinsic aspect ratio (DOM and tab order run down each column); grid uses uniform 4:3 tiles with the image cropped to cover.
size sm | md | lg md Tile width. The column count comes from container queries on the grid's own box, so a grid in a narrow pane shows fewer columns.
lightbox bool true Each image is an image-viewer trigger that opens the full-size original with zoom and pan. false renders plain lazy images.
label string | null null Accessible name of the list, for example "Onboarding screens".
emptyText string | null null Text of the empty state when there are no items and the slot is empty. Defaults to "No images yet."
id mixed | null null Declared by @props in the registry Blade source.
src mixed | null null Declared by @props in the registry Blade source.
original mixed | null null Declared by @props in the registry Blade source.
alt mixed | null null Declared by @props in the registry Blade source.
width mixed | null null Declared by @props in the registry Blade source.
height mixed | null null Declared by @props in the registry Blade source.
title mixed | null null Declared by @props in the registry Blade source.
meta mixed | null null Declared by @props in the registry Blade source.
href mixed | null null Declared by @props in the registry Blade source.

Slots

  • default — Your own <x-ui.media-grid.item> tiles, used instead of items. A tile takes the same keys as an items entry as props (id, src, original, alt, width, height, title, meta, href), reads layout and lightbox from the grid, and has an actions slot: controls over its top-end corner (remove, menu) that show on hover, whenever focus is inside the tile, and always on touch screens.
  • empty — Replaces the whole empty state, for example with a call to upload.
  • x-ui.media-grid.item — Installed subcomponent from the registry item.

Data slots

Stable hooks for CSS overrides and browser tests.

media-grid media-grid-empty media-grid-image media-grid-item media-grid-item-actions media-grid-item-caption media-grid-item-link media-grid-item-media media-grid-item-meta media-grid-item-title media-grid-list

Behavior

  • Renders a <ul role="list"> of tiles; masonry uses CSS columns with break-inside-avoid, grid uses grid tracks. Both read the column count from the root's inline size (@container), so the grid fits a split pane or sidebar.
  • In masonry, DOM order and tab order run down each column, then to the next column. Use grid when reading order across rows matters.
  • Images load lazily with width and height reserving their space. With lightbox, the image is the image-viewer trigger; the full-size original loads only when the viewer opens.
  • The caption title links to href when given, so the link and the lightbox are two separate targets.
  • Each tile with an id carries wire:key="media-grid-<id>" so Livewire morphs tiles in place; a wire:key you pass yourself wins.
  • Without items or slot content the empty state renders instead of an empty list.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, behaviorTest, authoredStateFixtures, responsive, rtl, darkMode, localized.

Guidance

Media selection and presentation

Select, preview, or compare visual media.

Use when

  • Use when image or video selection, preview, or media-based comparison is part of the task.
  • Showing a collection of images in an app (inspiration boards, screenshot libraries, uploaded media) from data, where each image can open at full size with zoom.
  • A Livewire view that renders its own tiles with per-tile actions (remove, menu) and needs stable wire:key morphing.
  • A grid that sits in a split pane or sidebar layout: the column count follows the grid's own width, not the viewport.

Avoid when

  • Do not require media-heavy interaction when a simpler text or list solution would be faster.
  • A marketing page section with a heading and a fixed set of showcase images; use the gallery-grid or gallery-masonry block.
  • Picking images from a set; use image-picker.
  • One image to inspect; use image-viewer on its own.
  • An ordered sequence of screens; use flow-strip.

Use instead

  • Text or list representation

Anti-patterns

  • Requiring media-heavy interaction when text is faster
Anatomy
media-grid media-grid-list media-grid-item media-grid-item-media media-grid-image media-grid-item-actions media-grid-item-caption media-grid-item-link media-grid-item-title media-grid-item-meta media-grid-empty
Theming hooks
media-grid media-grid-item image-viewer

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
Tab Enter Space Escape
Focus
Each image is a button that opens image-viewer; focus moves to the viewer stage and returns to the tile on close.
  • The list has an optional accessible name (label); each image keeps its required alt text, which is also the lightbox trigger's name.
  • The hover shade and the actions are never the only way to reach anything: actions also show on focus-within and on touch screens, and the caption is always visible.
  • The lightbox inherits image-viewer's keyboard zoom, focus move to the stage and focus return to the tile.
  • Focus rings stay visible: the image trigger, caption link and actions each draw the shared ring token.
  • 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

Safe

Livewire can update this component through forwarded wire:* attributes.

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/media-grid.blade.php Blade
@props([
    // Tiles: [['id', 'src', 'original'?, 'alt', 'width'?, 'height'?, 'title'?, 'meta'?, 'href'?], ...].
    // Leave empty and render media-grid.item tiles in the default slot
    // instead when a Livewire view draws its own tiles (with actions).
    'items' => [],
    // 'masonry': CSS columns, each tile keeps its intrinsic aspect ratio.
    // 'grid': uniform 4:3 tiles, the image covers the tile.
    'layout' => 'masonry',
    // Tile width: 'sm' | 'md' | 'lg'. The column count follows the width of
    // the grid's own box (container queries), so it also fits a split pane.
    'size' => 'md',
    // Each image opens image-viewer with zoom. false renders plain images.
    'lightbox' => true,
    // Accessible name of the list, for example "Onboarding screens".
    'label' => null,
    // Shown when there are no items and the slot is empty. The `empty` slot
    // replaces the whole empty state.
    'emptyText' => null,
])

@php
    $layout = in_array($layout, ['masonry', 'grid'], true) ? $layout : 'masonry';
    $size = in_array($size, ['sm', 'md', 'lg'], true) ? $size : 'md';
    $lightbox = filter_var($lightbox, FILTER_VALIDATE_BOOLEAN);

    // Column counts per tile width, read against the root's inline size
    // (@container). Masonry flows through CSS columns; grid through grid tracks.
    $columns = [
        'masonry' => [
            'sm' => 'columns-2 gap-4 @md:columns-3 @2xl:columns-4 @4xl:columns-5 @6xl:columns-6',
            'md' => 'columns-1 gap-4 @xs:columns-2 @2xl:columns-3 @4xl:columns-4 @6xl:columns-5',
            'lg' => 'columns-1 gap-4 @xl:columns-2 @4xl:columns-3 @7xl:columns-4',
        ],
        'grid' => [
            'sm' => 'grid grid-cols-2 gap-4 @md:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5 @6xl:grid-cols-6',
            'md' => 'grid grid-cols-1 gap-4 @xs:grid-cols-2 @2xl:grid-cols-3 @4xl:grid-cols-4 @6xl:grid-cols-5',
            'lg' => 'grid grid-cols-1 gap-4 @xl:grid-cols-2 @4xl:grid-cols-3 @7xl:grid-cols-4',
        ],
    ];

    $hasSlot = $slot->isNotEmpty();
    $items = array_values(is_array($items) ? $items : []);
    $isEmpty = ! $hasSlot && $items === [];
@endphp

<div
    data-slot="media-grid"
    data-layout="{{ $layout }}"
    data-size="{{ $size }}"
    {{ $attributes->merge(['class' => '@container w-full min-w-0 text-foreground']) }}
>
    {{-- Always rendered, hidden while there are tiles, so the empty slot's
         markup is stable for a Livewire morph between empty and filled. --}}
    <div data-slot="media-grid-empty" @if (! $isEmpty) hidden @endif>
        @if (isset($empty) && $empty->isNotEmpty())
            {{ $empty }}
        @else
            <x-ui.empty size="sm">
                <x-ui.empty.description>{{ filled($emptyText) ? $emptyText : __('No images yet.') }}</x-ui.empty.description>
            </x-ui.empty>
        @endif
    </div>
    @if (! $isEmpty)
        <ul
            role="list"
            data-slot="media-grid-list"
            @if (filled($label)) aria-label="{{ $label }}" @endif
            class="{{ $columns[$layout][$size] }}"
        >
            @if ($hasSlot)
                {{ $slot }}
            @else
                {{-- layout and lightbox reach each tile through @aware. --}}
                @foreach ($items as $item)
                    <x-ui.media-grid.item
                        :id="$item['id'] ?? null"
                        :src="$item['src'] ?? null"
                        :original="$item['original'] ?? null"
                        :alt="$item['alt'] ?? null"
                        :width="$item['width'] ?? null"
                        :height="$item['height'] ?? null"
                        :title="$item['title'] ?? null"
                        :meta="$item['meta'] ?? null"
                        :href="$item['href'] ?? null"
                    />
                @endforeach
            @endif
        </ul>
    @endif
</div>
resources/views/components/ui/media-grid/item.blade.php Blade
@aware([
    'layout' => 'masonry',
    'lightbox' => true,
])

@props([
    // Stable item id. Keys the tile for Livewire (wire:key="media-grid-<id>").
    'id' => null,
    // Display image (the tile). Required.
    'src' => null,
    // Full-size image for the lightbox and its Open original link. Falls back to src.
    'original' => null,
    // Required text alternative.
    'alt' => null,
    // Intrinsic size, so the page reserves the tile's space before the image loads.
    'width' => null,
    'height' => null,
    // Visible caption title; links to href when given. Also the lightbox title.
    'title' => null,
    // Secondary caption line (source, date); also the lightbox caption.
    'meta' => null,
    // Detail page of the item. The caption title links here, separate from
    // the image, which opens the lightbox.
    'href' => null,
])

@php
    if (blank($src)) {
        throw new \InvalidArgumentException('media-grid items need a src.');
    }
    if (blank($alt)) {
        throw new \InvalidArgumentException('media-grid items need a non-empty alt text.');
    }

    $layout = in_array($layout, ['masonry', 'grid'], true) ? $layout : 'masonry';
    $lightbox = filter_var($lightbox, FILTER_VALIDATE_BOOLEAN);
    $isGrid = $layout === 'grid';
    $linkText = filled($title) ? $title : $alt;
    $hasCaption = filled($title) || filled($meta) || filled($href);
    $hasActions = isset($actions) && $actions->isNotEmpty();

    // Grid tiles share one 4:3 box and crop; masonry tiles keep their own aspect.
    $frame = $isGrid ? 'aspect-4/3' : '';
    $image = $isGrid ? 'block size-full object-cover' : 'block h-auto w-full';
    $tileAttributes = filled($id) ? ['wire:key' => 'media-grid-'.$id, 'data-id' => $id] : [];
@endphp

<li
    data-slot="media-grid-item"
    {{ $attributes->merge($tileAttributes + ['class' => 'group/media-tile flex min-w-0 flex-col gap-2'.($isGrid ? '' : ' mb-4 break-inside-avoid')]) }}
>
    <div data-slot="media-grid-item-media" class="relative min-w-0 {{ $frame }}">
        @if ($lightbox)
            <x-ui.image-viewer
                fill
                :src="$src"
                :original="$original"
                :alt="$alt"
                :title="$title"
                :caption="$meta"
                :width="$width"
                :height="$height"
            >
                <x-slot:trigger>
                    <img
                        src="{{ $src }}"
                        alt="{{ $alt }}"
                        @if (filled($width)) width="{{ (int) $width }}" @endif
                        @if (filled($height)) height="{{ (int) $height }}" @endif
                        loading="lazy"
                        decoding="async"
                        data-slot="media-grid-image"
                        class="{{ $image }} bg-muted"
                    />
                </x-slot:trigger>
            </x-ui.image-viewer>
        @else
            <div class="size-full overflow-hidden rounded-md bg-muted">
                <img
                    src="{{ $src }}"
                    alt="{{ $alt }}"
                    @if (filled($width)) width="{{ (int) $width }}" @endif
                    @if (filled($height)) height="{{ (int) $height }}" @endif
                    loading="lazy"
                    decoding="async"
                    data-slot="media-grid-image"
                    class="{{ $image }}"
                />
            </div>
        @endif

        {{-- Hairline edge and hover shade, drawn over the image. Decorative and
             click-through, so the image keeps its own pointer target. --}}
        <span aria-hidden="true" class="pointer-events-none absolute inset-0 rounded-md ring-1 ring-inset ring-border group-hover/media-tile:bg-foreground/5"></span>

        @if ($hasActions)
            {{-- Tile actions show on hover and whenever focus is inside the
                 tile, and always on touch screens: never hover-only. --}}
            <div
                data-slot="media-grid-item-actions"
                class="absolute end-2 top-2 flex items-center gap-1 opacity-0 group-hover/media-tile:opacity-100 group-focus-within/media-tile:opacity-100 pointer-coarse:opacity-100"
            >
                {{ $actions }}
            </div>
        @endif
    </div>

    @if ($hasCaption)
        <div data-slot="media-grid-item-caption" class="flex min-w-0 flex-col gap-1">
            @if (filled($href))
                <a
                    href="{{ $href }}"
                    data-slot="media-grid-item-link"
                    {{-- Without a title the link text is the alt text, which also
                         names the lightbox button: give the link its own name. --}}
                    @if (blank($title)) aria-label="{{ __('Details for :name', ['name' => $alt]) }}" @endif
                    class="line-clamp-2 min-w-0 break-words rounded-sm text-sm font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring focus-visible:ring-offset-[length:var(--ring-offset-width)] focus-visible:ring-offset-background"
                >{{ $linkText }}</a>
            @elseif (filled($title))
                <p data-slot="media-grid-item-title" class="line-clamp-2 min-w-0 break-words text-sm font-medium text-foreground">{{ $title }}</p>
            @endif
            @if (filled($meta))
                <p data-slot="media-grid-item-meta" class="min-w-0 break-words text-xs text-muted-foreground">{{ $meta }}</p>
            @endif
        </div>
    @endif
</li>

Ownership & lifecycle

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