Skip to main content
Copy for LLM

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

Open a single image

The trigger may sit inside another component with state of its own. The common case is an icon button named by a tooltip; openAt() opens this lightbox from there as it does from anywhere else in the slot.

A thumbnail as the trigger

Any element in the slot works as the trigger. Here a clickable <x-wirekit::image> thumbnail opens the full-size view:

Click the thumbnail

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:

Step through a set

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:

Wide and tall images both fit

Zoom inside the image

Add zoomable and an open image can be enlarged: the weave of a fabric, the print on a label, the detail a fitted image is too small to show. Every way in moves the same zoom:

  • Buttons: Zoom in, Zoom out and Reset zoom, in the top-left corner of the viewer. A step scales by 1.5 around the middle of the screen.
  • Keys: + (or =) and - step in and out, 0 returns to the fitted size.
  • Wheel and trackpad: zoom around the pointer.
  • Double click or double tap: toggle between the fitted size and twice it, around the point.
  • Pinch with two fingers on a touch screen, around the point between them.
  • Drag the enlarged image with a mouse, a pen or one finger.
  • Pan buttons: while the image is enlarged, a row under the zoom buttons moves it a tenth of the screen at a time, as the arrow keys do, for a pointer that cannot drag.

The enlarged image may use the whole screen, not only the area it was fitted to. Moving to another item, or closing the viewer, puts the image back at its fitted size.

Zoom into a photo

While an image is enlarged, the arrow keys move the view across it instead of stepping through the items. 0 or Reset zoom gives them back to the items, and the Previous and Next buttons step in either state. Ctrl or Cmd with +, - or 0 stays the browser's own page zoom.

max-zoom sets the largest scale as a multiple of the fitted size, 4 unless you say otherwise. That scale has nothing to do with the image's own pixels, so an image enlarged past its natural size goes soft: serve a file large enough for the zoom you allow. An <x-wirekit::image-gallery> loads each item's full address in its lightbox for that reason, and takes zoomable and max-zoom itself.

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:

Video in a lightbox

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

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

Stops at the ends

Captions off

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

No captions

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:

A multi-line caption

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:

A darker backdrop

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:

{{-- 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.

Embedded content and untrusted URLs

An embed item renders inside an <iframe>, which is a different security question from an image or a video. Two things happen automatically, and one is still yours.

The scheme is checked. Only http:, https:, protocol-relative (//host/...) and scheme-less paths reach the frame. Anything else — javascript:, data: — is replaced with an empty src, so the slide renders as an empty frame rather than running. This matters because a javascript: URL in an iframe src executes in the host page's context, not the frame's: an embed URL built from user-submitted content would otherwise be a script-execution channel.

The frame is sandboxed. It runs with allow-scripts allow-same-origin allow-presentation allow-popups allow-popups-to-escape-sandbox — enough for the usual video players, and short of letting the frame navigate the page that opened it.

Same-origin embeds still get full access

allow-same-origin is what third-party players need, and it means an embed pointing at your own origin is not isolated from the page around it — it can reach the document that opened it. Never pass an embed URL you do not control or trust; the scheme check stops javascript: and data:, and it cannot tell a hostile https: page from a friendly one.

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

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 loading spinner, not the caption. The caption is a hard text-white on purpose — it sits on --color-wk-overlay over arbitrary media, where --color-wk-text-inverse would go near-black under .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, and the dimmed zoom control at either end of the scale.
--transition-wk-duration The fade of a loaded image, and a step of the zoom buttons and keys.

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.
lockScroll bool true Lock the page's scroll and make the page behind inert while the viewer is open. Set to false for a viewer that lives inside a page region, such as a preview, where the page around it has to keep scrolling.
loop bool true Whether prev / next wraps around at the ends. false dims the control at each end and marks it aria-disabled — it stays focusable, so a keyboard reader does not lose their place when the button they are standing on becomes the one that will not move.
showCaptions bool true Render each item's caption under the media.
slideOverlay Closure|null null Per-slide overlay render-callback fn($slide, $i): markup laid over the media of slide $i, shown only while that slide is. The wrapper is pointer-events-none; give an interactive control inside it pointer-events: auto. Return a view or HtmlString for HTML.
overlay string|null null Backdrop color / opacity for this instance (any CSS color). Null → the --color-wk-overlay token.
zoomable bool false Zoom inside an open image: the Zoom in, Zoom out and Reset zoom buttons, + - 0, the wheel, a double click or a double tap, a pinch, and dragging the enlarged image or moving it with the pan buttons or the arrow keys. Images only; a video or an embed keeps its own controls. See Zoom inside the image.
maxZoom int|float 4 The largest scale with zoomable, as a multiple of the fitted size. A value of 1 or less falls back to 4.
scope string|null null Scoped personalization key.

Accessibility

  • An aria-label or aria-labelledby of yours names the viewer.
  • 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.
  • The page behind is inert while the viewer is open, so neither a pointer nor a screen reader that ignores aria-modal reaches it. A toast region stays reachable, and a viewer with :lock-scroll="false" leaves the page alone.
  • Every prev / next / close control has an accessible name; the media carries its alt (decorative items pass alt="").
  • The controls stay clear of a notch. On a page whose viewport meta tag sets viewport-fit to cover, close, prev / next and the zoom controls stand in from a notch, a camera island or a home indicator by its size, so none sits where a phone covers it.
  • Every slide change is announced. Stepping with the arrow keys or the prev / next controls swaps the media and leaves focus where it was, so the overlay carries a polite live region that reads the new position followed by the slide's own alt — or its caption where there is no alt. The sentence is translated with your application's locale.
  • At the ends of a non-looping gallery the control is aria-disabled, not disabled. A natively disabled button leaves the tab order, which would take focus away from the reader at the exact moment they pressed it — inside a modal, with nothing visible to say where focus went. The dimmed control stays where it is and announces that it is unavailable.
  • Motion (the fade / scale entrance) respects prefers-reduced-motion — users who ask for less motion get an instant open.
  • A click inside keeps the keys working. A click on the image, or on a control in a browser that does not focus a clicked button, leaves focus on the viewer rather than on the page, so the arrow keys keep reaching it.
  • The zoom controls are real buttons with translated names, inside the focus trap. Zoom out and Reset zoom are aria-disabled at the fitted size and Zoom in at the largest scale, for the reason the prev / next controls are. A step of the buttons or the keys is announced politely ("Zoom 150%"); a gesture is not, because whoever pinches sees the result. Under reduced motion the steps are not animated.
  • Provide a real, keyboard-focusable trigger (a <button> or <x-wirekit::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)
+ / = Zoom in, with zoomable
- Zoom out, with zoomable
0 Back to the fitted size, with zoomable
← → ↑ ↓ on an enlarged image Move the view across the image, with zoomable. At the fitted size ← and → step through the items again

Further Reading

  • Overlay Events — the canonical event vocabulary for showing, closing and reacting to every overlay, with payload shapes and Livewire-side dispatch examples.

Updated in WireKit v2.66.0 (2026-10-13)

Was this page helpful?

Thank you for your feedback!

Voting requires cookies or local storage. What we store