Tutorials 4 min read

How to create an image gallery with Tailwind CSS: JavaScript and Alpine.js

Build an image gallery with a lightbox modal using Tailwind CSS, two ways: vanilla JavaScript or Alpine.js. Click a thumbnail to open, press Escape to close.

Today we are building a simple image gallery with a lightbox modal. We’ll build it twice, first with vanilla JavaScript, then with Alpine.js, so you can pick whichever fits your stack.

An image gallery displays a collection of images in a visually appealing grid, and a modal shows the full-size version when a thumbnail is clicked. Tailwind handles the layout, so the script only has to open the modal, swap the image source, and close it again.

Use cases

  • Product pages: let shoppers browse photos of a product and inspect them up close.
  • Portfolios: present your work as a grid with full-size previews on click.
  • Event recaps: gather photos from a conference, wedding, or meetup in one place.
  • Blog posts: group screenshots or illustrations without cluttering the article.

The markup

  • id="image-gallery": the container that holds the gallery, we listen for clicks on it.
  • data-image-url="...": stores the URL of the image that the modal will display.
  • id="modal": the modal element that shows the clicked image.
  • id="modal-content": the content wrapper inside the modal.
  • id="modal-close-button": the button that closes the modal.
  • id="modal-image": the image element inside the modal.
html
<div id="image-gallery">
  <div>
    <div data-image-url="...">
      <img src="..." />
    </div>
    <!-- Add more image placeholders as needed -->
  </div>
  <!-- Modal -->
  <div id="modal">
    <div id="modal-content">
      <button id="modal-close-button">Close</button>
      <img id="modal-image" />
    </div>
  </div>
</div>

The script

  • document.addEventListener("DOMContentLoaded", function () {: waits until the page is loaded before grabbing the elements.
  • gallery.addEventListener("click", function (event) {: when an img inside the gallery is clicked, it reads the parent’s data-image-url, sets it as the modal image’s src, and shows the modal by swapping hidden for flex flex-col.
  • function closeModal() {: hides the modal again by adding hidden back and removing flex flex-col.
  • Clicking the modal backdrop, clicking the close button, or pressing Escape all call closeModal(), so every way out works.
js
document.addEventListener("DOMContentLoaded", function () {
  const gallery = document.getElementById("image-gallery");
  const modal = document.getElementById("modal");
  const modalImage = document.getElementById("modal-image");
  const closeModalButton = document.getElementById("modal-close-button");

  gallery.addEventListener("click", function (event) {
    if (event.target.tagName === "IMG") {
      const imageUrl =
        event.target.parentElement.getAttribute("data-image-url");
      modalImage.src = imageUrl;
      modal.classList.remove("hidden");
      modal.classList.add("flex", "flex-col");
    }
  });

  function closeModal() {
    modal.classList.add("hidden");
    modal.classList.remove("flex", "flex-col");
  }

  modal.addEventListener("click", function (event) {
    if (
      event.target.id === "modal" ||
      event.target.id === "modal-close-button"
    ) {
      closeModal();
    }
  });

  closeModalButton.addEventListener("click", closeModal);

  document.addEventListener("keydown", function (event) {
    if (event.key === "Escape") {
      closeModal();
    }
  });
});

The Alpine.js version

Same component, no separate script: the state lives in x-data.

  • x-data="{ currentImage: null }": stores the currently open image, null means the modal is closed.
  • x-init: adds a keydown listener to the window so pressing Escape sets currentImage back to null.
  • x-on:click="currentImage = '/path-to-your/image.png'": opens the modal with that image.
  • x-show="currentImage": shows the modal only while an image is selected.
  • @click.away="currentImage = null" and the close button’s click handler close the modal.
  • :src="currentImage": displays the selected image inside the modal.

Classes are removed for brevity, but I’ll keep those classes relevant to the tutorial.

html
<div
  x-data="{ currentImage: null }"
  x-init="() => {
        window.addEventListener('keydown', (event) => {
            if (event.key === 'Escape') {
                currentImage = null;
            }
        });
    }">
  <div class="grid grid-cols-3 gap-4">
    <div
      x-on:click="currentImage = '/path-to/image-1.png'">
      <img
        src="/path-to/image-1.png"
        alt="Image 1"
      />
    </div>
    <div
      x-on:click="currentImage = '/path-to/image-2.png'">
      <img
        src="/path-to/image-2.png"
        alt="Image 2"
      />
    </div>
    <div
      x-on:click="currentImage = '/path-to/image-3.png'">
      <img
        src="/path-to/image-3.png"
        alt="Image 3"
      />
    </div>
    <!-- Add more image placeholders as needed -->
  </div>
  <!-- Modal -->
  <div
    x-show="currentImage"
    role="dialog"
    aria-modal="true"
    aria-labelledby="modal-title">
    <div
      @click.away="currentImage = null"
      tabindex="-1"
      aria-labelledby="modal-title"
      aria-describedby="modal-description">
      <button
        @click="currentImage = null"
        >Close</button
      >
      <img
        :src="currentImage"
        alt="Full Size Image"

      />
    </div>
  </div>
</div>

You can try the Alpine version separately: live demo and source code.

Which one should you use?

If Alpine is already loaded on your page, the x-data version keeps everything in the markup. If not, the vanilla script does the same job without adding a dependency.

Conclusion

A grid of thumbnails, a modal, and a bit of state: that’s the whole gallery. Before shipping it, make sure it’s fully accessible and responsive, and feel free to add captions, zoom, or previous and next buttons.

Hope you enjoyed this tutorial and have a great day!

/Michael Andreuzza