Lightbox
<x-wirekit:: 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:: 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:
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:: thumbnail opens the full-size view:
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:
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:
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,0returns 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.
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:: 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:
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:
Captions off
Captions render under the media by default. Turn them off with
:show-captions="false" for a clean, chrome-free viewer:
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:
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:
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.
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-labeloraria-labelledbyof 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
inertwhile the viewer is open, so neither a pointer nor a screen reader that ignoresaria-modalreaches 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 passalt=""). - The controls stay clear of a notch. On a page whose viewport meta tag sets
viewport-fittocover, 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 noalt. The sentence is translated with your application's locale. - At the ends of a non-looping gallery the control is
aria-disabled, notdisabled. 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-disabledat 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::) so the lightbox is reachable without a pointer.button>
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.