An image gallery with a lightbox is one of those components that sounds simple until you're debugging z-index stacking, keyboard traps, and scroll-lock all at once. The good news is that Alpine.js and Tailwind CSS together give you exactly the right amount of tool — reactive state without a build pipeline for the JS side, and utility classes that make the overlay layout near-trivial.
This tutorial builds a complete, production-ready gallery component from scratch. By the end you'll have a responsive grid that opens a full-screen lightbox with previous/next navigation, keyboard support, and an accessible focus trap — all in a single HTML file (or a single Astro component, if that's your setup).
What you need
- Tailwind CSS v4 (loaded via CDN or your existing build pipeline)
- Alpine.js v3 (loaded via CDN script tag or npm)
- A list of image URLs — the examples below use placeholder images you can swap out
If you're on Tailwind v3, the utility classes used here are identical. The only v4-specific addition in this tutorial is an optional @theme block for a custom transition duration token. Everything else is vanilla Tailwind utilities that work in both versions.
Step 1: Load Alpine.js and Tailwind
For a standalone page or a quick prototype, CDN is fine. For an Astro project, install both packages and import Alpine in your layout.
CDN (quickest start)
<!-- In your <head> -->
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
<!-- Tailwind v4 play CDN -->
<script src="https://cdn.tailwindcss.com?plugins=forms"></script>
Astro + npm
npm install alpinejs
npm install tailwindcss @tailwindcss/vite
In your Astro layout's client script:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
And in your CSS entry point (Tailwind v4 CSS-first setup):
@import "tailwindcss";
@theme {
--duration-lightbox: 200ms;
}
That @theme block is optional — it just gives you an animate-lightbox-style token if you want to reference it in custom CSS. The utilities we actually use (duration-200, ease-in-out) are built into Tailwind already.
Step 2: Define the component data
Alpine's x-data directive is where all the reactive state lives. The gallery needs to know which images exist, which one is currently open, and whether the lightbox is visible at all.
<div
x-data="{
images: [
{ src: 'https://picsum.photos/seed/a/1200/800', alt: 'Mountain landscape at dawn' },
{ src: 'https://picsum.photos/seed/b/1200/800', alt: 'Coastal cliffs and ocean' },
{ src: 'https://picsum.photos/seed/c/1200/800', alt: 'Forest path in autumn' },
{ src: 'https://picsum.photos/seed/d/1200/800', alt: 'Desert dunes at sunset' },
{ src: 'https://picsum.photos/seed/e/1200/800', alt: 'Snowy peaks at midday' },
{ src: 'https://picsum.photos/seed/f/1200/800', alt: 'River canyon from above' }
],
open: false,
current: 0,
openAt(index) {
this.current = index;
this.open = true;
},
close() {
this.open = false;
},
prev() {
this.current = (this.current - 1 + this.images.length) % this.images.length;
},
next() {
this.current = (this.current + 1) % this.images.length;
}
}"
>
A few things worth noting here. The prev() and next() methods use the modulo operator to wrap around — going left from index 0 jumps to the last image, going right from the last jumps back to 0. The openAt(index) method sets the active image before toggling the lightbox open, so there's never a flash of the wrong image.
Step 3: Build the thumbnail grid
Inside the same x-data wrapper, render the grid using x-for to loop over the images array. Each thumbnail is a button — not a div — so it's keyboard-focusable by default.
<!-- Thumbnail grid -->
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3 md:grid-cols-4">
<template x-for="(image, index) in images" :key="index">
<li>
<button
type="button"
class="group w-full overflow-hidden rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@click="openAt(index)"
:aria-label="'Open lightbox for: ' + image.alt"
>
<img
:src="image.src"
:alt="image.alt"
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
</button>
</li>
</template>
</ul>
The group-hover:scale-105 trick on the image gives a subtle zoom effect when you hover the button without needing any JavaScript. The aspect-video utility (16:9) keeps every thumbnail the same height regardless of the source image's natural dimensions — critical for a clean grid.
Step 4: Build the lightbox overlay
The lightbox is a fixed, full-viewport overlay that sits above everything else. Alpine's x-show handles visibility and x-transition adds the fade-in/out without a single line of custom CSS.
<!-- Lightbox overlay -->
<div
x-show="open"
x-transition:enter="transition ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4"
role="dialog"
aria-modal="true"
:aria-label="images[current].alt"
@click.self="close()"
@keydown.escape.window="close()"
@keydown.arrow-left.window="prev()"
@keydown.arrow-right.window="prev()"
tabindex="-1"
x-init="$watch('open', val => { if (val) $nextTick(() => $el.focus()) })"
>
Several details here matter for real-world use:
@click.self="close()"— the.selfmodifier means clicking the dark backdrop closes the lightbox, but clicking the image itself does not. Without.self, any click anywhere on the overlay would close it, including clicks on the navigation buttons.@keydown.escape.window="close()"and the arrow key handlers use the.windowmodifier so the events are caught even when focus is on a child element inside the overlay.- The
x-init/$watchcombo focuses the overlay div whenever it opens, which enables the keydown listeners and creates a rudimentary focus trap for keyboard users. tabindex="-1"makes the div programmatically focusable without adding it to the natural tab order.
The image and navigation buttons
Inside the overlay div, add the active image and prev/next/close controls:
<!-- Close button -->
<button
type="button"
class="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
@click="close()"
aria-label="Close lightbox"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Previous -->
<button
type="button"
class="absolute left-4 rounded-full bg-white/10 p-3 text-white hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
@click="prev()"
aria-label="Previous image"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<!-- Active image -->
<img
:src="images[current].src"
:alt="images[current].alt"
class="max-h-[85vh] max-w-full rounded-lg object-contain shadow-2xl"
/>
<!-- Next -->
<button
type="button"
class="absolute right-4 rounded-full bg-white/10 p-3 text-white hover:bg-white/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
@click="next()"
aria-label="Next image"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
<!-- Counter -->
<p class="absolute bottom-4 left-1/2 -translate-x-1/2 text-sm text-white/70">
<span x-text="current + 1"></span> / <span x-text="images.length"></span>
</p>
</div> <!-- end lightbox -->
</div> <!-- end x-data -->
The counter at the bottom (x-text) reactively displays the current position. Because current is zero-based, adding 1 gives the human-readable index.
Step 5: Prevent body scroll when the lightbox is open
One detail that separates a polished implementation from a quick one: when the lightbox opens, the page behind it should not scroll. Alpine's $watch makes this easy to add to your x-data object:
x-init="
$watch('open', val => {
document.body.style.overflow = val ? 'hidden' : '';
if (val) $nextTick(() => $el.focus());
})
"
Replace the earlier x-init attribute on the overlay div with this version on the root x-data div (or merge both watchers). When open becomes true, the body overflow is locked; when it closes, it resets to the browser default.
Step 6: Animate the image transition (optional)
The default behavior swaps the image immediately when you navigate. If you want a crossfade between images, wrap the <img> in a container and use Alpine's x-transition with a key change. A simpler approach that works well in practice: add a short CSS transition on opacity using x-bind:key set to current, combined with x-transition:
<template x-if="open">
<img
:key="current"
x-transition:enter="transition-opacity duration-150"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
:src="images[current].src"
:alt="images[current].alt"
class="max-h-[85vh] max-w-full rounded-lg object-contain shadow-2xl"
/>
</template>
Note: when using x-if with :key, Alpine destroys and re-creates the element on each navigation, which triggers the enter transition fresh every time. This is a lightweight alternative to a full crossfade and adds minimal overhead.
Accessibility checklist
- role="dialog" and aria-modal="true" on the overlay — tells screen readers this is a modal context
- aria-label on the overlay bound to the current image alt text — announces the image when the lightbox opens
- aria-label on every button — icon-only buttons must have a text label
- focus management — the overlay receives focus on open; close returns focus naturally to the thumbnail that triggered it (browsers do this automatically when the overlay is removed from the DOM via
x-show) - Keyboard navigation — Escape closes, arrow keys navigate; all three are wired above
Putting it all together
The complete component is roughly 80 lines of HTML. There are no third-party lightbox libraries, no extra npm packages beyond Alpine and Tailwind themselves, and no custom JavaScript files to maintain. The state lives in x-data, the transitions live in utility classes, and the markup is semantic enough that a screen reader can follow along without special handling.
From here, natural next steps include lazy-loading the full-size images (add loading="lazy" to thumbnails and preload the active lightbox image on openAt), adding swipe gesture support for mobile via Alpine's @touchstart/@touchend directives, or pulling the image array from an Astro content collection so the gallery is driven entirely by data files rather than hardcoded markup.
The pattern scales well. A portfolio gallery, a before/after comparison strip, a product screenshot carousel — they're all variations on the same three primitives: an x-data object that knows which item is active, an x-for loop that renders the grid, and an x-show overlay that presents the detail view. Once you have the structure down, the variations are just data.