---
title: Lightbox
description: Focus-trapped media viewer for images, video, and embeds — opened from any control
visibility: guest
draft: false
---

# Lightbox

`<x-wirekit::lightbox>` is a reusable, accessible zoom overlay for **media** —
photos, video, and embeds. It is a focus-trapped `role="dialog"` that opens over
the page, steps through its items with the arrow keys, closes on Escape, and
returns focus to whatever opened it. It is the same overlay
[`<x-wirekit::image-gallery>`](/components/image-gallery) uses internally, exposed
on its own so you can drive it from any trigger and any layout — a single
thumbnail, a "play" button, a link in prose.

Pass an array of `items`, each with a `src`, an `alt`, an optional `caption`, and
an optional `type` (`image` — the default — `video`, or `embed`). Then open it two
ways: a trigger **inside** the component calls `openAt(index)` directly, or any
control **anywhere** on the page dispatches a `wirekit-lightbox-open` event with a
matching `name`.

## Usage

Put a trigger in the default slot — it shares the lightbox's Alpine scope, so it
can call `openAt(0)` to open at the first item:

:::preview{title="Open a single image"}
<x-wirekit::lightbox name="lb-single" :items="[
    ['src' => '/placeholder/960x600?bg=0f172a&fg=ffffff&label=Photo&gradient=none', 'alt' => 'A wide landscape photo', 'caption' => 'A wide landscape photo'],
]">
    <x-wirekit::button x-on:click="openAt(0)">View photo</x-wirekit::button>
</x-wirekit::lightbox>
:::

## A thumbnail as the trigger

Any element in the slot works as the trigger. Here a clickable
[`<x-wirekit::image>`](/components/image) thumbnail opens the full-size view:

:::preview{title="Click the thumbnail"}
<x-wirekit::lightbox name="lb-thumb" :items="[
    ['src' => '/placeholder/1200x800?bg=4f46e5&fg=ffffff&label=Full&gradient=none', 'alt' => 'Mountain range at dusk', 'caption' => 'Mountain range at dusk'],
]">
    <button type="button" x-on:click="openAt(0)" aria-label="View photo: Mountain range at dusk" style="display: inline-block; cursor: zoom-in; border: 0; background: transparent; padding: 0; max-width: 16rem;">
        <x-wirekit::image src="/placeholder/320x200?bg=4f46e5&fg=ffffff&label=Thumb&gradient=none" alt="Mountain range at dusk" ratio="16/10" fit="cover" rounded />
    </button>
</x-wirekit::lightbox>
:::

## Multiple items

Give the lightbox several items and it gains prev / next controls plus arrow-key
navigation. Any control in the slot can jump straight to a specific index:

:::preview{title="Step through a set"}
<x-wirekit::lightbox name="lb-set" :items="[
    ['src' => '/placeholder/960x600?bg=0f172a&fg=ffffff&label=1&gradient=none', 'alt' => 'Slide one', 'caption' => 'Slide one'],
    ['src' => '/placeholder/960x600?bg=059669&fg=ffffff&label=2&gradient=none', 'alt' => 'Slide two', 'caption' => 'Slide two'],
    ['src' => '/placeholder/960x600?bg=b91c1c&fg=ffffff&label=3&gradient=none', 'alt' => 'Slide three', 'caption' => 'Slide three'],
]">
    <x-wirekit::row gap="sm">
        <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open at first</x-wirekit::button>
        <x-wirekit::button intent="neutral" x-on:click="openAt(2)">Open at last</x-wirekit::button>
    </x-wirekit::row>
</x-wirekit::lightbox>
:::

## Large images scale to fit

Whatever the source dimensions, the media fills most of the screen with its
aspect ratio intact — it never overflows or forces the page to scroll:

- A **very wide** panorama is capped to **~90% of the viewport width**
  (`max-w-[90vw]`); its height scales down to match.
- A **very tall** image is capped to **~85% of the viewport height**
  (`max-h-[85vh]`); its width scales down to match.

`object-contain` keeps both from stretching. Open each below — a 21:9 panorama
and a 9:21 column both land fully inside the viewport, using the available space:

:::preview{title="Wide and tall images both fit"}
<x-wirekit::lightbox name="lb-large" :items="[
    ['src' => '/placeholder/2000x858?bg=0f172a&fg=ffffff&label=Wide&gradient=none', 'alt' => 'A very wide 2000×858 21:9 panorama', 'caption' => 'A very wide 2000×858 (21:9) panorama — a 2000px-wide source, capped to ~90% of the viewport width; its height scales down to keep the aspect ratio'],
    ['src' => '/placeholder/858x2000?bg=4f46e5&fg=ffffff&label=Tall&gradient=none', 'alt' => 'A very tall 858×2000 9:21 column', 'caption' => 'A very tall 858×2000 (9:21) column — a 2000px-tall source, capped to ~85% of the viewport height; its width scales down to keep the aspect ratio'],
]">
    <x-wirekit::row gap="sm">
        <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open wide image</x-wirekit::button>
        <x-wirekit::button intent="neutral" x-on:click="openAt(1)">Open tall image</x-wirekit::button>
    </x-wirekit::row>
</x-wirekit::lightbox>
:::

## Lazy loading and the loading spinner

Off-screen slides are **not downloaded up front**. Each image is
`loading="lazy"`, so only the slide you are viewing (and one you navigate to)
fetches its full-size source — a gallery of large photos does not pay for all of
them on open. Embeds are lazy too; videos preload only their metadata.

While a large image is still downloading, the viewer shows a **loading spinner**
in its place, then fades the image in once it has loaded. There is nothing to
configure — it is automatic for every image item. (Open the wide or tall image
above on a throttled connection to see the spinner before the image resolves.)

## Video and embeds

Set `type` to `video` for a native `<video>` player, or `embed` for an
`<iframe>` (an embedded map, a video host, a document viewer). A `video` item
also takes an optional `poster` still, shown before the clip paints its first
frame — so the surface is not blank while it buffers. Here a poster image with a
play affordance opens the clip in the lightbox:

:::preview{title="Video in a lightbox"}
<x-wirekit::lightbox name="lb-video" :items="[
    ['src' => '/media/wirekit-sample-clip.mp4', 'type' => 'video', 'poster' => '/media/wirekit-sample-clip-poster.jpg', 'alt' => 'A short sample clip', 'caption' => 'A short sample clip (no audio)'],
]">
    <button type="button" x-on:click="openAt(0)" aria-label="Play the sample clip" style="position: relative; display: inline-block; cursor: pointer; border: 0; background: transparent; padding: 0; max-width: 20rem;">
        <x-wirekit::image src="/media/wirekit-sample-clip-poster.jpg" alt="" ratio="16/9" fit="cover" rounded />
        <span style="position: absolute; inset: 0; display: grid; place-items: center; color: #fff; filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.7));">
            <x-wirekit::icon name="play" class="h-14 w-14" />
        </span>
    </button>
</x-wirekit::lightbox>
:::

Mix types freely in one lightbox — the viewer, focus trap, and keyboard model
are identical:

```blade
<x-wirekit::lightbox name="product-media" :items="[
    ['src' => $photo->url, 'alt' => 'Product photo', 'caption' => 'Front view'],
    ['src' => $clip->url, 'type' => 'video', 'poster' => $clip->posterUrl, 'alt' => 'Product in use', 'caption' => 'In use'],
    ['src' => $tourUrl, 'type' => 'embed', 'alt' => '360° tour', 'caption' => '360° tour'],
]">
    <x-wirekit::button x-on:click="openAt(1)">Watch the clip</x-wirekit::button>
</x-wirekit::lightbox>
```

## Loop on or off

By default prev / next wrap around at the ends. Set `:loop="false"` and the
controls stop — the prev control is disabled on the first item, next on the last:

:::preview{title="Stops at the ends"}
<x-wirekit::lightbox name="lb-noloop" :loop="false" :items="[
    ['src' => '/placeholder/960x600?bg=1e293b&fg=ffffff&label=A&gradient=none', 'alt' => 'A', 'caption' => 'A'],
    ['src' => '/placeholder/960x600?bg=7c3aed&fg=ffffff&label=B&gradient=none', 'alt' => 'B', 'caption' => 'B'],
]">
    <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open (no loop)</x-wirekit::button>
</x-wirekit::lightbox>
:::

## Captions off

Captions render under the media by default. Turn them off with
`:show-captions="false"` for a clean, chrome-free viewer:

:::preview{title="No captions"}
<x-wirekit::lightbox name="lb-nocap" :show-captions="false" :items="[
    ['src' => '/placeholder/960x600?bg=334155&fg=ffffff&label=Clean&gradient=none', 'alt' => 'A clean view', 'caption' => 'This caption is hidden'],
]">
    <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open (no captions)</x-wirekit::button>
</x-wirekit::lightbox>
:::

## A longer caption

A caption is not limited to a short label. A full sentence wraps onto multiple
lines, stays centered under the media, and never runs wider than the image —
good for a credit line, a description, or a note about what the reader is looking
at:

:::preview{title="A multi-line caption"}
<x-wirekit::lightbox name="lb-longcap" :items="[
    ['src' => '/placeholder/1200x800?bg=065f46&fg=ffffff&label=Detail&gradient=none', 'alt' => 'A detailed photograph', 'caption' => 'A longer caption wraps onto multiple lines, stays centered under the image, and never runs wider than the media. Use it for a full sentence of context — a credit line, a description of the scene, or a note about what the reader is looking at.'],
]">
    <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open with a long caption</x-wirekit::button>
</x-wirekit::lightbox>
:::

## Backdrop color per instance

The backdrop uses the themeable `--color-wk-overlay` token by default. Override it
per instance with `overlay` — any CSS color, so you can dim harder for photos or
tint the surround to match a brand:

:::preview{title="A darker backdrop"}
<x-wirekit::lightbox name="lb-overlay" overlay="rgba(0, 0, 0, 0.92)" :items="[
    ['src' => '/placeholder/960x600?bg=0891b2&fg=ffffff&label=Dim&gradient=none', 'alt' => 'A photo on a darker backdrop', 'caption' => 'A darker backdrop'],
]">
    <x-wirekit::button intent="neutral" x-on:click="openAt(0)">Open (dark backdrop)</x-wirekit::button>
</x-wirekit::lightbox>
:::

## Open from anywhere (event-driven)

A control does not have to live inside the lightbox. Give the lightbox a stable
`name`, then dispatch a `wirekit-lightbox-open` event with that name and the
target index from any element on the page — a table row, a card, a menu item:

```blade
{{-- The lightbox lives once, near the end of the page --}}
<x-wirekit::lightbox name="product-media" :items="$media" />

{{-- Any control, anywhere, opens it at a chosen index --}}
<button
    type="button"
    x-on:click="$dispatch('wirekit-lightbox-open', { name: 'product-media', index: 3 })"
>
    Open the fourth image
</button>
```

The event carries `{ name, index }`; only the lightbox whose `name` matches
responds. This keeps one overlay instance for many triggers instead of nesting a
lightbox per thumbnail.

## Design tokens

The lightbox styles itself entirely from design tokens, so it re-themes with the
rest of WireKit. Retint these to restyle it (see [theming](/theming)):

| Token | Drives |
| --- | --- |
| `--color-wk-overlay` | The backdrop color (default — override per instance with `overlay`). |
| `--z-wk-modal` | The stacking level, above page content. |
| `--radius-wk-md` | The corner radius on the media and controls. |
| `--shadow-wk-lg` | The elevation of the media surface. |
| `--shadow-wk-md` | The elevation of the prev / next / close controls. |
| `--color-wk-bg-elevated` | The control button background. |
| `--color-wk-text` | The control icon color. |
| `--color-wk-ring` + `--ring-wk-width` | The keyboard focus ring on the controls. |
| `--color-wk-bg` | The caption text color (light-on-dark). |
| `--text-wk-sm` | The caption text size. |
| `--space-wk-md` / `--space-wk-sm` | Overlay padding, control insets, and the media-to-caption gap. |
| `--opacity-wk-disabled` | The dimmed prev / next control when `loop` is off at an end. |

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `name` | `string\|null` | auto | Identifier this instance answers to for the `wirekit-lightbox-open` event. Auto-generated when omitted. |
| `items` | `array` | `[]` | The slides — each an array with `src`, `alt`, optional `caption`, optional `type` (`image` / `video` / `embed`), and an optional `poster` (video-only still shown before the clip paints). A plain string is a decorative image `src`. |
| `loop` | `bool` | `true` | Whether prev / next wraps around at the ends. `false` disables the control at each end. |
| `showCaptions` | `bool` | `true` | Render each item's caption under the media. |
| `overlay` | `string\|null` | `null` | Backdrop color / opacity for this instance (any CSS color). Null → the `--color-wk-overlay` token. |
| `scope` | `string\|null` | `null` | Scoped personalization key. |

## Accessibility

- The overlay is a `role="dialog"` `aria-modal="true"` surface, **focus-trapped**
  with the same helper Modal and Drawer use — focus cannot reach the page behind
  it, and it returns to the trigger on close.
- Every prev / next / close control has an accessible name; the media carries its
  `alt` (decorative items pass `alt=""`).
- Motion (the fade / scale entrance) respects `prefers-reduced-motion` — users who
  ask for less motion get an instant open.
- Provide a real, keyboard-focusable trigger (a `<button>` or
  [`<x-wirekit::button>`](/components/button)) so the lightbox is reachable
  without a pointer.

## Keyboard Interaction

| Key | Action |
| --- | --- |
| `Enter` / `Space` | Open the lightbox from the focused trigger |
| `Escape` | Close the lightbox (focus returns to the trigger) |
| `←` | Previous item |
| `→` | Next item |
| `Tab` | Move between the lightbox controls (focus stays trapped inside) |
