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 Andreuzza

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 .astro file.

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 with src and alt properties
  • currentIndex: Tracks which image is currently displayed in the lightbox
  • isOpen: Boolean to control lightbox visibility
  • openLightbox(index): Opens the lightbox and sets the current image
  • closeLightbox(): Closes the lightbox modal
  • next(): 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 clicked
  • aspect-square: Ensures all thumbnails have a 1:1 aspect ratio
  • hover:opacity-80: Provides visual feedback on hover
  • cursor-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" and aria-modal="true": Announces to screen readers that this is a modal dialog
  • x-init="$el.focus()": Automatically focuses the lightbox when opened for keyboard navigation
  • tabindex="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 effect
  • supports-[backdrop-filter]: Progressive enhancement for browsers that support backdrop filters
  • Dark mode support with dark: variants

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 div acts as a viewport with overflow-hidden
  • The inner div contains 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 elements
  • aria-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-only headings
  • ✅ 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

Did you like this post? Please share it with your friends!