A gallery with a lightbox is one of those UI patterns that looks simple until you actually build it. The thumbnail grid is easy. The overlay that opens when you click one, slides between images, closes on Escape, and doesn't trap focus — that's where most implementations cut corners. This tutorial builds the whole thing from scratch using Tailwind CSS v4 and Alpine.js, with keyboard navigation and proper accessibility attributes baked in from the start.
The finished component is self-contained HTML. No npm packages beyond Tailwind and Alpine. No build step if you use the CDN. It works in any Astro page, any Laravel Blade template, or plain HTML.
What you are building
- A responsive grid of thumbnail images
- A full-screen lightbox overlay that opens on click
- Previous / next navigation via buttons and arrow keys
- Close on Escape key or clicking the backdrop
- Smooth fade + scale transition on the overlay
- Dot indicators showing which image is active
Setting up Tailwind CSS v4
Tailwind v4 dropped tailwind.config.js. Configuration now lives in your CSS file using the @theme directive, and the whole framework imports with a single line.
If you are using a bundler (Vite, Astro, etc.), install the package and import it at the top of your CSS entry file:
@import "tailwindcss";
@theme {
--color-brand: oklch(55% 0.22 265);
--radius-card: 0.75rem;
}
If you just want to prototype without a build step, add the CDN play build to your HTML <head>:
<script src="https://cdn.tailwindcss.com"></script>
Note: the CDN play build is fine for development and prototyping but not for production — run a proper build there to get the optimized output.
Setting up Alpine.js
Alpine v3 is a drop-in script. No bundler required:
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
Place this before the closing </body> tag (or in <head> with defer). If you are using Astro or another framework with a bundler, install it as a package instead:
npm install alpinejs
Then import and start it in your entry JS:
import Alpine from 'alpinejs';
window.Alpine = Alpine;
Alpine.start();
The Alpine.js data model
The entire gallery state lives in a single x-data object. This is the core of the Alpine approach — one source of truth, no external state management needed.
{
images: [
{ src: '/photos/01.jpg', alt: 'Stone arch bridge at dawn' },
{ src: '/photos/02.jpg', alt: 'Forest trail in autumn' },
{ src: '/photos/03.jpg', alt: 'Coastal cliffs at low tide' },
{ src: '/photos/04.jpg', alt: 'Mountain lake reflection' },
{ src: '/photos/05.jpg', alt: 'Desert sand dunes' },
{ src: '/photos/06.jpg', alt: 'City skyline at dusk' }
],
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;
}
}
open drives the lightbox visibility. current is the active image index. The prev() and next() methods use modulo arithmetic so navigation wraps around at the ends. openAt(index) sets both pieces of state atomically so there is no flash of a wrong image when the overlay appears.
The thumbnail grid
The grid is a straightforward x-for loop. Alpine renders one thumbnail per image object:
<div
x-data="gallery()"
class="p-6"
>
<!-- Thumbnail grid -->
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<template x-for="(image, index) in images" :key="index">
<button
type="button"
class="group relative overflow-hidden rounded-lg aspect-square focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
@click="openAt(index)"
:aria-label="'Open lightbox: ' + image.alt"
>
<img
:src="image.src"
:alt="image.alt"
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
/>
</button>
</template>
</div>
<!-- Lightbox (see next section) -->
</div>
A few things worth noting here. Using a <button> element (rather than a <div> with a click handler) means keyboard users can tab to each thumbnail and activate it with Enter or Space without any extra work. The loading="lazy" attribute defers off-screen images, keeping initial page load fast even for large galleries. The hover scale effect on the image is a subtle signal that the thumbnail is interactive.
The lightbox overlay
The lightbox is a fixed overlay that covers the full viewport. It uses x-show for visibility and x-transition for the fade-in animation. Keyboard events are captured with @keydown.window so they fire regardless of which element has focus inside the overlay.
<!-- 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"
@keydown.escape.window="close()"
@keydown.arrow-left.window="prev()"
@keydown.arrow-right.window="next()"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
role="dialog"
aria-modal="true"
:aria-label="images[current]?.alt"
x-cloak
>
<!-- Backdrop click closes -->
<div class="absolute inset-0" @click="close()" aria-hidden="true"></div>
<!-- Close button -->
<button
type="button"
class="absolute top-4 right-4 z-10 rounded-full bg-white/10 p-2 text-white hover:bg-white/20 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-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
<!-- Active image -->
<div class="relative z-10 flex max-h-screen max-w-5xl items-center px-14">
<img
:src="images[current]?.src"
:alt="images[current]?.alt"
class="max-h-[85vh] max-w-full rounded object-contain shadow-2xl"
/>
</div>
<!-- Previous button -->
<button
type="button"
class="absolute left-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
@click.stop="prev()"
aria-label="Previous image"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clip-rule="evenodd" />
</svg>
</button>
<!-- Next button -->
<button
type="button"
class="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white hover:bg-white/20 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
@click.stop="next()"
aria-label="Next image"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd" />
</svg>
</button>
<!-- Dot indicators -->
<div class="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 gap-2">
<template x-for="(image, index) in images" :key="index">
<button
type="button"
class="h-2 w-2 rounded-full transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
:class="index === current ? 'bg-white' : 'bg-white/40'"
@click.stop="current = index"
:aria-label="'Go to image ' + (index + 1)"
></button>
</template>
</div>
</div>
Wiring it together with a function
Rather than inlining the full data object directly in x-data — which gets unwieldy — define it as a named function in a <script> tag and reference it by name. This keeps the HTML clean and makes the logic easy to test or reuse:
<script>
function gallery() {
return {
images: [
{ src: '/photos/01.jpg', alt: 'Stone arch bridge at dawn' },
{ src: '/photos/02.jpg', alt: 'Forest trail in autumn' },
{ src: '/photos/03.jpg', alt: 'Coastal cliffs at low tide' },
{ src: '/photos/04.jpg', alt: 'Mountain lake reflection' },
{ src: '/photos/05.jpg', alt: 'Desert sand dunes' },
{ src: '/photos/06.jpg', alt: 'City skyline at dusk' }
],
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;
}
}
}
</script>
Then the root element simply references it: x-data="gallery()".
Hiding the lightbox before Alpine loads
Add this to your CSS to prevent a flash of the open lightbox state before Alpine initializes:
[x-cloak] { display: none !important; }
The x-cloak attribute on the lightbox <div> — shown in the markup above — works together with this rule. Alpine removes the attribute once it has processed the element, at which point x-show takes over visibility control.
Using it in Astro
In an Astro component, pass your image data as a prop and render the gallery with is:inline or include Alpine via the layout. The key detail is that Alpine directives are evaluated at runtime in the browser, so they survive Astro's static rendering just fine — no special integration needed. If you want the gallery data to come from an Astro content collection, fetch it in the frontmatter and serialize it into the x-data attribute:
---
// src/components/Gallery.astro
const { images } = Astro.props;
const serialized = JSON.stringify(images);
---
<div x-data={`galleryFromData(${serialized})`}>
<!-- same markup as above -->
</div>
Then define galleryFromData in your JS to accept an initial images array rather than hardcoding it.
Accessibility checklist
- The overlay has
role="dialog"andaria-modal="true"so screen readers announce it correctly. - Every interactive element is a
<button>, not a<div>— keyboard and screen reader accessible by default. - All buttons have
aria-labelattributes (close, previous, next, each dot). - The
:aria-labelbinding on the dialog itself reflects the current image alt text. - Escape closes the overlay (
@keydown.escape.window). - Arrow keys navigate (
@keydown.arrow-left.window/@keydown.arrow-right.window). - Focus management: for a production implementation, consider using
x-trapfrom the Alpine Focus plugin to trap focus inside the open dialog and restore it to the triggering thumbnail on close.
Common issues
The lightbox flashes open on page load
You are missing the [x-cloak] { display: none !important; } CSS rule, or you forgot to add the x-cloak attribute to the overlay element.
Keyboard events do not fire
If you used @keydown.escape without the .window modifier, the element itself must have focus for the event to fire. Use @keydown.escape.window to listen at the window level so it works regardless of focus position.
Clicking the image closes the lightbox
The image is inside the backdrop <div> that has @click="close()". Wrap the image in a <div class="relative z-10"> with @click.stop (or just omit a click handler on the image wrapper) to stop click events from bubbling to the backdrop. The markup above already handles this with @click.stop on nav buttons.
Images are the wrong size in the lightbox
Use max-h-[85vh] max-w-full object-contain on the lightbox <img>. This keeps the image within the viewport regardless of its natural dimensions while preserving its aspect ratio.
Where to go from here
The pattern above is intentionally minimal. From here you can layer in: touch/swipe support (Alpine has no built-in swipe, but a small custom directive is straightforward), image captions using an additional field on each image object, lazy loading via a custom x-intersect directive so full-resolution images only load when the lightbox opens, or a zoom mode that toggles between object-contain and object-cover. Each of these is a small, isolated addition to the same data model — which is the point of keeping state in one x-data object to begin with.
The combination of Tailwind's utility classes and Alpine's reactive directives keeps the total JavaScript to a few dozen lines and the HTML readable. There is no virtual DOM, no component lifecycle to manage, and nothing to bundle or tree-shake beyond what Tailwind's own build already handles.