lexington®
Use code LEX35 at checkout
7k+ customers.
How to build an interactive image gallery with lightbox using Alpine.js and Tailwind CSS
Create a fully accessible image gallery with lightbox modal, keyboard navigation, and smooth transitions using Alpine.js and Tailwind CSS.
Published on October 2, 2025 by Michael AndreuzzaBuilding an Alpine.js image gallery with lightbox
Image galleries are essential for showcasing portfolios, products, or any visual content. In this tutorial, we’ll build a responsive grid gallery with a full-featured lightbox modal that includes keyboard navigation, smooth transitions, and accessibility features.
We will:
- Create a responsive image grid with Tailwind CSS
- Build a lightbox modal with Alpine.js state management
- Add keyboard navigation (arrows and escape key)
- Implement navigation dots and previous/next buttons
- Ensure full accessibility with ARIA attributes and focus management
Prerequisites: This tutorial assumes you have Tailwind CSS and Alpine.js set up in your project. If you’re using Astro, you can drop this component directly into any
.astrofile.
1. Set up the Alpine.js data structure
The gallery component uses Alpine.js’s x-data directive to manage the state. Here’s what we need to track:
<section
x-data="{
gallery: [
{src: '/images/1.png', alt: 'Gallery image 1'},
{src: '/images/2.png', alt: 'Gallery image 2'},
{src: '/images/3.png', alt: 'Gallery image 3'},
{src: '/images/4.png', alt: 'Gallery image 4'},
{src: '/images/5.png', alt: 'Gallery image 5'},
{src: '/images/6.png', alt: 'Gallery image 6'},
{src: '/images/7.png', alt: 'Gallery image 7'},
{src: '/images/8.png', alt: 'Gallery image 8'}
],
currentIndex: 0,
isOpen: false,
openLightbox(index) {
this.currentIndex = index;
this.isOpen = true;
},
closeLightbox() {
this.isOpen = false;
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.gallery.length;
},
prev() {
this.currentIndex = (this.currentIndex - 1 + this.gallery.length) % this.gallery.length;
}
}"
>
<!-- Gallery content will go here -->
</section>
Breaking down the state:
gallery: An array of image objects withsrcandaltpropertiescurrentIndex: Tracks which image is currently displayed in the lightboxisOpen: Boolean to control lightbox visibilityopenLightbox(index): Opens the lightbox and sets the current imagecloseLightbox(): Closes the lightbox modalnext(): Advances to the next image (wraps around to first image at the end)prev(): Goes to the previous image (wraps around to last image at the beginning)
The modulo operator (%) ensures the navigation wraps around seamlessly, creating an infinite carousel effect.
2. Create the responsive image grid
Now let’s build the thumbnail grid. We’ll use Tailwind’s responsive grid utilities to create a layout that adapts from 1 column on mobile to 4 columns on large screens:
<div class="px-8 py-24 mx-auto max-w-7xl md:px-12 lg:px-20">
<p class="text-sm pb-12 mt-4 italic text-center text-zinc-500 dark:text-zinc-400">
Click on the image to open it in a lightbox.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 lg:grid-cols-4">
<template x-for="(image, index) in gallery" :key="index">
<img
:src="image.src"
:alt="image.alt"
class="object-cover cursor-pointer size-full aspect-square hover:opacity-80 transition-opacity"
@click="openLightbox(index)"
/>
</template>
</div>
</div>
Key points:
x-for: Loops through the gallery array to render each image@click="openLightbox(index)": Passes the image index when clickedaspect-square: Ensures all thumbnails have a 1:1 aspect ratiohover:opacity-80: Provides visual feedback on hovercursor-pointer: Changes cursor to indicate clickability
3. Build the lightbox modal
The lightbox is a full-screen overlay that displays the selected image. It uses Alpine’s x-show directive for visibility control:
<div
x-show="isOpen"
x-init="$el.focus()"
role="dialog"
aria-modal="true"
aria-label="Image gallery"
tabindex="0"
@keydown.escape="closeLightbox"
@keydown.arrow-left="prev"
@keydown.arrow-right="next"
class="fixed inset-0 bg-white/80 dark:bg-zinc-900/80 backdrop-blur supports-[backdrop-filter]:bg-white/60 supports-[backdrop-filter]:dark:bg-zinc-900/60 flex items-start justify-center z-50 overflow-auto p-8 md:p-12 lg:p-32 outline-none"
>
<div class="relative w-full max-w-3xl mx-auto" @click.stop>
<!-- Lightbox content -->
</div>
</div>
Accessibility features:
role="dialog"andaria-modal="true": Announces to screen readers that this is a modal dialogx-init="$el.focus()": Automatically focuses the lightbox when opened for keyboard navigationtabindex="0": Makes the element focusable@keydown.escape="closeLightbox": Closes lightbox when pressing Escape@keydown.arrow-left="prev"and@keydown.arrow-right="next": Arrow key navigation
Visual design:
backdrop-blur: Creates a frosted glass effectsupports-[backdrop-filter]: Progressive enhancement for browsers that support backdrop filters- Dark mode support with
dark:variants
4. Create the vertical carousel
The lightbox uses a vertical carousel to display images. We transform the container vertically based on the current index:
<div class="relative w-full max-w-3xl mx-auto" @click.stop>
<h2 class="sr-only">Image Gallery Carousel</h2>
<!-- Carousel Wrapper -->
<div class="overflow-hidden h-[50vh] sm:h-[60vh] md:h-[80vh]">
<div
class="flex flex-col h-full transition-transform duration-500"
x-bind:style="`transform: translateY(-${currentIndex * 100}%)`"
>
<template x-for="(item, idx) in gallery" :key="idx">
<div class="flex items-center justify-center flex-shrink-0 h-full">
<img
:src="item.src"
:alt="item.alt"
class="object-cover object-top w-auto lg:h-120 aspect-square"
/>
</div>
</template>
</div>
</div>
</div>
How it works:
- The outer
divacts as a viewport withoverflow-hidden - The inner
divcontains all images stacked vertically translateY(-${currentIndex * 100}%): Shifts the entire stack up by the current index- Each image takes up 100% of the viewport height (
h-full) transition-transform duration-500: Smooth sliding animation
5. Add navigation controls
Previous/Next buttons
Add vertical arrow buttons on the left side of the lightbox:
<div class="absolute z-20 left-2 top-1/2 transform -translate-y-1/2">
<div class="flex flex-col gap-2">
<!-- Next button (up arrow moves to next image) -->
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-900 dark:outline-zinc-100 dark:hover:bg-zinc-200 dark:focus:outline-zinc-200 size-9 p-2 text-sm rounded-md"
aria-label="Next image"
@click.stop="next"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-up size-8"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 15l6 -6l6 6"></path>
</svg>
</button>
<!-- Previous button (down arrow moves to previous image) -->
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-900 dark:outline-zinc-100 dark:hover:bg-zinc-200 dark:focus:outline-zinc-200 size-9 p-2 text-sm rounded-md"
aria-label="Previous image"
@click.stop="prev"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-down size-8"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 9l6 6l6 -6"></path>
</svg>
</button>
</div>
</div>
Important details:
@click.stop: Prevents click events from bubbling up to parent elementsaria-label: Provides descriptive labels for screen readers- Positioned absolutely and centered vertically with
top-1/2 transform -translate-y-1/2
Dot indicators
Add pagination dots on the right side:
<div class="absolute z-20 right-2 top-1/2 transform -translate-y-1/2">
<div class="flex flex-col justify-center mt-4 gap-2">
<template x-for="(_, idx) in gallery" :key="idx">
<button
@click="currentIndex = idx"
class="w-2 h-2 rounded-full"
:class="idx === currentIndex ? 'bg-white' : 'bg-white/50 hover:bg-white'"
:aria-label="`Go to image ${idx + 1}`"
></button>
</template>
</div>
</div>
Features:
- Creates one dot per image
- Highlights the current image with full opacity
- Clicking a dot jumps directly to that image
- Accessible labels for each dot
Close button
Add a close button in the top-right corner:
<button
class="absolute z-50 top-2 right-2"
aria-label="Close gallery"
@click.stop="closeLightbox"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-x size-4 text-black dark:text-white hover:text-zinc-300"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
6. Complete code example
Here’s the full component put together:
<section
class="bg-white dark:bg-zinc-900"
x-data="{
gallery: [
{src: '/images/1.png', alt: 'Gallery image 1'},
{src: '/images/2.png', alt: 'Gallery image 2'},
{src: '/images/3.png', alt: 'Gallery image 3'},
{src: '/images/4.png', alt: 'Gallery image 4'},
{src: '/images/5.png', alt: 'Gallery image 5'},
{src: '/images/6.png', alt: 'Gallery image 6'},
{src: '/images/7.png', alt: 'Gallery image 7'},
{src: '/images/8.png', alt: 'Gallery image 8'}
],
currentIndex: 0,
isOpen: false,
openLightbox(index) {
this.currentIndex = index;
this.isOpen = true;
},
closeLightbox() {
this.isOpen = false;
},
next() {
this.currentIndex = (this.currentIndex + 1) % this.gallery.length;
},
prev() {
this.currentIndex = (this.currentIndex - 1 + this.gallery.length) % this.gallery.length;
}
}"
>
<!-- Gallery Grid -->
<div class="px-8 py-24 mx-auto max-w-7xl md:px-12 lg:px-20">
<p class="text-sm pb-12 mt-4 italic text-center text-zinc-500 dark:text-zinc-400">
Click on the image to open it in a lightbox.
</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 lg:grid-cols-4">
<template x-for="(image, index) in gallery" :key="index">
<img
:src="image.src"
:alt="image.alt"
class="object-cover cursor-pointer size-full aspect-square hover:opacity-80 transition-opacity"
@click="openLightbox(index)"
/>
</template>
</div>
</div>
<!-- Lightbox Modal -->
<div
x-show="isOpen"
x-init="$el.focus()"
role="dialog"
aria-modal="true"
aria-label="Image gallery"
tabindex="0"
@keydown.escape="closeLightbox"
@keydown.arrow-left="prev"
@keydown.arrow-right="next"
class="fixed inset-0 bg-white/80 dark:bg-zinc-900/80 backdrop-blur supports-[backdrop-filter]:bg-white/60 supports-[backdrop-filter]:dark:bg-zinc-900/60 flex items-start justify-center z-50 overflow-auto p-8 md:p-12 lg:p-32 outline-none"
>
<div class="relative w-full max-w-3xl mx-auto" @click.stop>
<h2 class="sr-only">Image Gallery Carousel</h2>
<!-- Carousel Container -->
<div class="overflow-hidden h-[50vh] sm:h-[60vh] md:h-[80vh]">
<div
class="flex flex-col h-full transition-transform duration-500"
x-bind:style="`transform: translateY(-${currentIndex * 100}%)`"
>
<template x-for="(item, idx) in gallery" :key="idx">
<div class="flex items-center justify-center flex-shrink-0 h-full">
<img
:src="item.src"
:alt="item.alt"
class="object-cover object-top w-auto lg:h-120 aspect-square"
/>
</div>
</template>
</div>
</div>
<!-- Navigation Arrows -->
<div class="absolute z-20 left-2 top-1/2 transform -translate-y-1/2">
<div class="flex flex-col gap-2">
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-900 dark:outline-zinc-100 dark:hover:bg-zinc-200 dark:focus:outline-zinc-200 size-9 p-2 text-sm rounded-md"
aria-label="Next image"
@click.stop="next"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-up size-8"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 15l6 -6l6 6"></path>
</svg>
</button>
<button
class="flex items-center justify-center text-center shadow-subtle font-medium duration-500 ease-in-out transition-colors focus:outline-2 focus:outline-offset-2 text-white bg-zinc-900 outline outline-zinc-900 hover:bg-zinc-950 focus:outline-zinc-950 dark:bg-zinc-100 dark:text-zinc-900 dark:outline-zinc-100 dark:hover:bg-zinc-200 dark:focus:outline-zinc-200 size-9 p-2 text-sm rounded-md"
aria-label="Previous image"
@click.stop="prev"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-chevron-down size-8"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M6 9l6 6l6 -6"></path>
</svg>
</button>
</div>
</div>
<!-- Dot Indicators -->
<div class="absolute z-20 right-2 top-1/2 transform -translate-y-1/2">
<div class="flex flex-col justify-center mt-4 gap-2">
<template x-for="(_, idx) in gallery" :key="idx">
<button
@click="currentIndex = idx"
class="w-2 h-2 rounded-full"
:class="idx === currentIndex ? 'bg-white' : 'bg-white/50 hover:bg-white'"
:aria-label="`Go to image ${idx + 1}`"
></button>
</template>
</div>
</div>
<!-- Close Button -->
<button
class="absolute z-50 top-2 right-2"
aria-label="Close gallery"
@click.stop="closeLightbox"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="icon icon-tabler-x size-4 text-black dark:text-white hover:text-zinc-300"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
</section>
7. Customization ideas
Add transitions
Alpine.js supports transition directives for smooth animations:
<div
x-show="isOpen"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
...
>
Add image counter
Display the current position in the gallery:
<div class="absolute top-4 left-1/2 transform -translate-x-1/2 text-white text-sm">
<span x-text="currentIndex + 1"></span> / <span x-text="gallery.length"></span>
</div>
Prevent body scroll
When the lightbox is open, prevent the body from scrolling:
<div
x-show="isOpen"
x-init="$watch('isOpen', value => {
document.body.style.overflow = value ? 'hidden' : 'auto'
})"
...
>
Add zoom functionality
Allow users to click the image to zoom in:
x-data="{
// ... existing properties
isZoomed: false,
toggleZoom() {
this.isZoomed = !this.isZoomed;
}
}"
Then add the toggle to your image:
<img
@click="toggleZoom"
:class="isZoomed ? 'scale-150 cursor-zoom-out' : 'cursor-zoom-in'"
class="transition-transform duration-300"
...
/>
8. Accessibility checklist
- ✅ Keyboard navigation (arrow keys, escape)
- ✅ Focus management with
x-init="$el.focus()" - ✅ ARIA attributes (
role,aria-modal,aria-label) - ✅ Semantic HTML with proper button elements
- ✅ Screen reader support with
sr-onlyheadings - ✅ Focus visible states for all interactive elements
- ✅ Clear visual feedback on hover and focus
Conclusion
You now have a fully functional, accessible image gallery with lightbox functionality built with Alpine.js and Tailwind CSS. This pattern can be adapted for product showcases, portfolio galleries, or any scenario where you need to display images in an interactive way.
The beauty of Alpine.js is that all the logic stays in your HTML, making it easy to understand and modify. The component is lightweight, doesn’t require a build step, and works great with modern frameworks like Astro, Laravel, or plain HTML.
Feel free to customize the styling, add more features, or adapt it to your specific needs!
/Michael Andreuzza