lexington®
Use code LEX40 at checkout
7k+ customers.
How to build a Tailwind CSS + Alpine.js gallery with lightbox
Create a responsive image gallery using Tailwind CSS and Alpine.js with a keyboard-accessible lightbox, arrows, and dot indicators. Copy‑paste example included.
Published on November 14, 2025 by Michael AndreuzzaBuild a responsive gallery with Tailwind CSS and Alpine.js that opens a keyboard-accessible lightbox with next/prev controls and dot indicators. We’ll keep the markup simple, add common accessibility attributes, and make sure it works across breakpoints. Throughout this guide, we’ll call out the specific Tailwind CSS utilities and Alpine.js directives so you can adapt them quickly.
Include Alpine.js
If Alpine.js isn’t already loaded globally, add it via CDN:
<script src="https://unpkg.com/alpinejs" defer></script>
Alpine.js state and methods
We’ll store an array of images in gallery, track the currentIndex, and toggle the lightbox with isOpen. Methods openLightbox, closeLightbox, next, and prev handle interactions.
Key events on the lightbox container support Escape to close and Arrow keys to navigate.
Tailwind CSS thumbnails grid
Use a responsive grid for thumbnails. Each image opens the lightbox with its index. Tailwind CSS utilities explained:
grid grid-cols-1: one column by default (mobile-first)sm:grid-cols-2 lg:grid-cols-4: increase columns at larger breakpointsgap-4: consistent spacing between thumbnailsaspect-square size-full object-cover: force a 1:1 square area and fill it neatly
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 lg:grid-cols-4">
<!-- Thumbnails with @click="openLightbox(index)" -->
</div>
Lightbox overlay (Tailwind CSS + Alpine.js)
The overlay uses Alpine.js x-show to toggle visibility and manages focus with tabindex="0". The inner carousel translates vertically based on currentIndex.
Accessibility touches:
- Role
dialog+aria-modal="true"and a visually-hidden heading - Keyboard controls: Escape, ArrowLeft, ArrowRight
- Buttons with clear
aria-labels
Relevant Tailwind CSS utilities on the overlay:
fixed inset-0 z-50: cover the viewport and stay above page contentbg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60: subtle backdrop with blur where supportedoverflow-auto p-8 md:p-12 lg:p-32: scroll large images and provide generous padding across breakpoints
Alpine.js bindings to note:
x-init="$el.focus()": focus the dialog container when opened@keydown.escape,@keydown.arrow-left,@keydown.arrow-right: keyboard navigation@click.stop: prevent background clicks from closing unintentionally
Styling tips for Tailwind CSS
- Use
object-coverand responsive heights on the lightbox image. - Add backdrop blur via
backdrop-blurwithsupports-[backdrop-filter]fallbacks. - Prefer concise copy and clear focus styles on controls.
Extra Tailwind CSS ideas:
- Swap
aspect-squareforaspect-[4/3]when you need landscape previews. - Replace
size-fullwithw-full h-fullif you don’t use Tailwind’s size utilities. - Add
transition-opacity+hover:opacity-80on thumbnails for a subtle affordance.
Alpine.js enhancements:
- Add
x-transitionto the lightbox wrapper for smooth fade-ins. - Persist
currentIndexacross sessions usinglocalStorageinx-initif desirable.
Component-by-component walkthrough
Root section and Alpine state
x-data: Initializes Alpine.js reactive state:gallery(images),currentIndex(active slide),isOpen(lightbox visibility).- Methods:
openLightbox,closeLightbox,next,prevdrive user interactions. - Tailwind CSS:
bg-whitesets a neutral backdrop for thumbnails.
<section
class="bg-white"
x-data='{
gallery: [
{"src":"https://oxbowui.com/_astro/1.BQ9qcFLx.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/2.CbneMX0b.png","alt":"Oxbow UI"}
],
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; }
}'
>
<!-- thumbnails + lightbox go here -->
</section>
Thumbnails grid
- Wrapper:
grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4builds a responsive grid that grows atsmandlgbreakpoints. - Template:
<template x-for="(item, i) in gallery">repeats a thumbnail per image. - Image:
object-cover aspect-square size-full cursor-pointer hover:opacity-80 transition-opacityensures square previews, good cropping, and a clear hover affordance. - Alpine.js:
@click="openLightbox(i)"opens the lightbox at the clicked index.
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<template x-for="(item, i) in gallery" :key="i">
<img
:src="item.src"
:alt="item.alt"
class="object-cover aspect-square size-full cursor-pointer hover:opacity-80 transition-opacity"
@click="openLightbox(i)"
/>
</template>
<!-- add more images to gallery to fill the grid -->
</div>
Lightbox overlay (dialog container)
- Alpine.js:
x-show="isOpen"toggles visibility;x-init="$el.focus()"moves focus into the dialog. - Accessibility:
role="dialog"+aria-modal="true"+ a visually hidden<h2>label the modal for assistive tech. - Keyboard:
@keydown.escape,@keydown.arrow-left,@keydown.arrow-rightenable Escape/Left/Right navigation. - Tailwind CSS:
fixed inset-0 z-50covers the viewport;bg-white/80 backdrop-bluradds a soft veil;overflow-auto p-8 md:p-12 lg:p-32supports large images and padding across breakpoints.
<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 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 goes here -->
</div>
</div>
Carousel track and slide mechanics
- Track:
flex flex-col h-full transition-transform duration-500stacks slides vertically and animates movement. - Transform:
x-bind:style="\transform: translateY(-${currentIndex * 100}%)`”` moves the track by slide height (100%). - Slides: each slide is
flex items-center justify-center flex-shrink-0 h-fullto center images and maintain full height. - Image in slide:
object-cover object-top w-auto lg:h-120 aspect-squarekeeps consistent framing and scales at large screens.
<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
- Placement: absolute container at
left-2 top-1/2 -translate-y-1/2pins controls to the left center. - Buttons: Tailwind classes add contrast and focus styles;
@click.stop="next"and@click.stop="prev"updatecurrentIndexwithout closing the modal. - Icons: up/down chevrons match the vertical carousel movement (next moves up the stack).
<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 size-9 p-2 text-sm rounded-md"
aria-label="Next image"
@click.stop="next"
>
<!-- up chevron icon -->
</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 size-9 p-2 text-sm rounded-md"
aria-label="Previous image"
@click.stop="prev"
>
<!-- down chevron icon -->
</button>
</div>
</div>
Dot indicators
- Template:
<template x-for="(_, idx) in gallery">renders one dot per image. - State:
:class="idx === currentIndex ? 'bg-white' : 'bg-white/50 hover:bg-white'"reflects active slide. - Interaction: clicking a dot sets
currentIndex = idxfor direct navigation.
<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: absolute at
top-2 right-2, with accessiblearia-label. - Alpine.js:
@click.stop="closeLightbox"prevents event bubbling and hides the overlay. - Visual: outline and hover classes keep it visible against varied content.
<button
class="absolute z-50 top-2 right-2"
aria-label="Close gallery"
@click.stop="closeLightbox"
>
<!-- close icon -->
<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-wave-x size-4 text-black 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>
Why a vertical carousel
- Vertical stacking simplifies height management (
h-[50vh] sm:h-[60vh] md:h-[80vh]). - Up/down arrow icons map intuitively to the direction of slide changes.
Full copy‑paste example
All image URLs are absolute (prefixed with https://oxbowui.com/) so they don’t break. Replace them with your own when integrating.
<!---
// Set `isOpen` to `false` to hide the lightbox.
// isOpen: true,
-->
<section
class="bg-white"
x-data='{
gallery: [
{"src":"https://oxbowui.com/_astro/1.BQ9qcFLx.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/2.CbneMX0b.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/3.zvHAWKQ6.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/4.DUHjK26u.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/5.toPeJZ4l.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/6.Cfc9s_sc.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/7.BAbOls3H.png","alt":"Oxbow UI"},
{"src":"https://oxbowui.com/_astro/8.Cx6WIK5R.png","alt":"Oxbow UI"}
],
currentIndex: 0,
isOpen: true,
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; }
}'
>
<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">
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="(item, i) in gallery" :key="i">
<img
:src="item.src"
:alt="item.alt"
class="object-cover cursor-pointer size-full aspect-square hover:opacity-80 transition-opacity"
@click="openLightbox(i)"
/>
</template>
</div>
</div>
<!-- Lightbox -->
<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 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>
<!-- Hidden heading for screen readers -->
<h2 class="sr-only">Image Gallery Carousel</h2>
<!-- Carousel Wrapper (responsive height) -->
<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 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"
slot="icon"
>
<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 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"
slot="icon"
>
<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-wave-x size-4 text-black hover:text-zinc-300"
slot="icon"
>
<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>
That’s it. You now have a lightweight gallery powered by Alpine.js and styled with Tailwind CSS, complete with keyboard support and a smooth, responsive lightbox.
/Michael Andreuzza