Responsive Photo Gallery Grid with Lightbox in HTML, Tailwind CSS & JS

Build a fast, responsive image gallery with category filtering and an accessible dialog lightbox using HTML5, Tailwind CSS and vanilla JavaScript.

Kavi Dev

Responsive Photo Gallery Grid with Lightbox in HTML, Tailwind CSS & JS

Image galleries show up in portfolio sites, agency showcases, and product pages. When developers build a gallery from scratch, two things usually go wrong: thumbnails with mixed portrait and landscape dimensions break the grid row heights, and custom lightbox overlays trap mouse users or fail to close with the keyboard.

Here is how our responsive photo gallery template solves both problems with clean HTML, Tailwind CSS, and the native <dialog> element.

Quick summary: Wrap thumbnails in aspect-[4/3] overflow-hidden containers with object-cover, filter categories with data-category attributes and aria-pressed buttons, and use a native <dialog> element for the full-screen lightbox.

1. Uniform Grid Thumbnails with aspect-[4/3] and object-cover

Even if your full-size images have different aspect ratios, keeping the grid thumbnails uniform makes the gallery much easier to scan. You can display the uncropped original aspect ratio inside the lightbox when the user clicks a card.

<!-- Filter buttons -->
<div role="group" aria-label="Filter gallery by category" class="mb-8 flex flex-wrap gap-2">
  <button type="button" data-filter="all" aria-pressed="true" class="rounded-full bg-blue-600 px-4 py-1.5 text-sm font-medium text-white">
    All
  </button>
  <button type="button" data-filter="ui" aria-pressed="false" class="rounded-full bg-slate-100 px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-200">
    UI Screens
  </button>
  <button type="button" data-filter="branding" aria-pressed="false" class="rounded-full bg-slate-100 px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-200">
    Branding
  </button>
</div>

<!-- Gallery grid -->
<div id="gallery-grid" class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
  <figure
    data-category="ui"
    class="group overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm"
  >
    <button
      type="button"
      data-full="/img/analytics-full.webp"
      data-caption="Analytics dashboard dark mode view"
      class="relative block aspect-[4/3] w-full overflow-hidden bg-slate-100 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600"
    >
      <img
        src="/img/analytics-thumb.webp"
        alt="Analytics dashboard dark mode view"
        width="800"
        height="600"
        loading="lazy"
        class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
      />
    </button>
    <figcaption class="px-4 py-3 text-sm font-medium text-slate-700">
      Analytics Dashboard UI
    </figcaption>
  </figure>
</div>

Notice that the clickable trigger inside <figure> is a <button type="button">, not a <div> with a click listener. That gives keyboard users native Enter and Space activation and a visible focus ring (focus-visible:ring-2).

2. Zero-Dependency Lightbox Using <dialog>

Instead of pulling in a third-party lightbox library, use the HTML <dialog> element. Every modern browser supports dialog.showModal(), which handles focus trapping and Escape key dismissal automatically:

<dialog
  id="lightbox"
  class="max-h-[90vh] max-w-4xl rounded-2xl bg-slate-900 p-2 text-white backdrop:bg-black/75"
>
  <div class="flex items-center justify-between px-3 py-2">
    <p id="lightbox-caption" class="text-sm text-slate-200"></p>
    <button
      id="lightbox-close"
      type="button"
      aria-label="Close image preview"
      class="rounded-lg px-3 py-1 text-sm font-semibold text-slate-200 hover:bg-white/10"
    >
      Close (Esc)
    </button>
  </div>
  <img
    id="lightbox-image"
    src=""
    alt=""
    class="max-h-[78vh] w-auto rounded-xl object-contain"
  />
</dialog>

3. Connecting Filter and Lightbox Logic in 30 Lines of JS

const filterButt
const cards = document.querySelectorAll('#gallery-grid [data-category]');
const dialog = document.getElementById('lightbox');
const dialogImg = document.getElementById('lightbox-image');
const dialogCaption = document.getElementById('lightbox-caption');

filterButtons.forEach((button) => {
  button.addEventListener('click', () => {
    const selected = button.getAttribute('data-filter');
    filterButtons.forEach((b) => b.setAttribute('aria-pressed', String(b === button)));

    cards.forEach((card) => {
      const matches = selected === 'all' || card.getAttribute('data-category') === selected;
      card.hidden = !matches;
    });
  });
});

document.querySelectorAll('#gallery-grid [data-full]').forEach((trigger) => {
  trigger.addEventListener('click', () => {
    const src = trigger.getAttribute('data-full');
    const caption = trigger.getAttribute('data-caption') || '';
    dialogImg.src = src;
    dialogImg.alt = caption;
    dialogCaption.textC
    dialog.showModal();
  });
});

document.getElementById('lightbox-close')?.addEventListener('click', () => dialog.close());
dialog?.addEventListener('click', (e) => {
  if (e.target === dialog) dialog.close(); // Click on backdrop closes modal
});

Because dialog.showModal() remembers which thumbnail button opened the modal, calling dialog.close() automatically returns focus to that exact thumbnail when the user closes the preview.

Frequently Asked Questions

Why use the native HTML dialog element for a lightbox?

Calling dialog.showModal() automatically places the lightbox in the browser's top layer, traps keyboard focus inside the modal, renders a backdrop pseudo-element, and closes when the user presses Escape — saving dozens of lines of custom JavaScript.

How do I stop gallery cards from jumping when images have different dimensions?

Give each thumbnail wrapper a consistent aspect ratio class such as aspect-[4/3] with overflow-hidden, and apply h-full w-full object-cover to the img element.

Does client-side category filtering work with lazy-loaded images?

Yes. When a hidden card has display: none (via the hidden attribute), modern browsers defer loading its lazy image until the filter reveals that card.

Follow Us

Share Your Feedback

Did you find this article helpful? We'd love to hear your thoughts and suggestions to improve our content.

Share Feedback