Black Weeks are live. Grab Lifetime Access to every theme for 40% OFF.

Use code LEX40 at checkout.

You’ll get every theme we’ve made — and every one we’ll ever make. Unlimited projects. Lifetime updates. One payment.

Ends soon. Use LEX40 before it’s gone.
Get full access

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 Andreuzza

Build 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 breakpoints
  • gap-4: consistent spacing between thumbnails
  • aspect-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>

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 content
  • bg-white/80 backdrop-blur supports-[backdrop-filter]:bg-white/60: subtle backdrop with blur where supported
  • overflow-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-cover and responsive heights on the lightbox image.
  • Add backdrop blur via backdrop-blur with supports-[backdrop-filter] fallbacks.
  • Prefer concise copy and clear focus styles on controls.

Extra Tailwind CSS ideas:

  • Swap aspect-square for aspect-[4/3] when you need landscape previews.
  • Replace size-full with w-full h-full if you don’t use Tailwind’s size utilities.
  • Add transition-opacity + hover:opacity-80 on thumbnails for a subtle affordance.

Alpine.js enhancements:

  • Add x-transition to the lightbox wrapper for smooth fade-ins.
  • Persist currentIndex across sessions using localStorage in x-init if 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, prev drive user interactions.
  • Tailwind CSS: bg-white sets 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-4 builds a responsive grid that grows at sm and lg breakpoints.
  • 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-opacity ensures 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-right enable Escape/Left/Right navigation.
  • Tailwind CSS: fixed inset-0 z-50 covers the viewport; bg-white/80 backdrop-blur adds a soft veil; overflow-auto p-8 md:p-12 lg:p-32 supports 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-500 stacks 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-full to center images and maintain full height.
  • Image in slide: object-cover object-top w-auto lg:h-120 aspect-square keeps 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/2 pins controls to the left center.
  • Buttons: Tailwind classes add contrast and focus styles; @click.stop="next" and @click.stop="prev" update currentIndex without 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 = idx for 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 accessible aria-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

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