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.
What is an image gallery?
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.
<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 animginside the gallery is clicked, it reads the parent’sdata-image-url, sets it as the modal image’ssrc, and shows the modal by swappinghiddenforflex flex-col.function closeModal() {: hides the modal again by addinghiddenback and removingflex flex-col.- Clicking the modal backdrop, clicking the close button, or pressing
Escapeall callcloseModal(), so every way out works.
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,nullmeans the modal is closed.x-init: adds akeydownlistener to the window so pressingEscapesetscurrentImageback tonull.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.
<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