← Back to blog

Build a Minimal Native JavaScript Image Gallery

A step-by-step tutorial to ship a polished, dependency-free gallery in under 100 lines of code.

Why a native gallery?

Image galleries are one of the most copied-and-pasted widgets on the web. Framework boilerplates and plugin bundles abound, yet most of them are excessive when you just need a small, reliable gallery that loads quickly and works everywhere. In this tutorial we will create a fully interactive gallery using nothing but HTML, CSS, and a pinch of native JavaScript. You will walk away with reusable snippets and an understanding of the moving parts, so you can adapt the gallery to any project without pulling in a dependency.

We will build the gallery in four checkpoints:

  1. Markup: semantic HTML that is accessible out of the box.
  2. Layout: lean CSS that scales from mobile to desktop.
  3. Interaction: plain JavaScript for image selection and keyboard support.
  4. Enhancements: progressive touches such as focus rings, transitions, and lazy loading.

The finished component weighs less than 5 KB unminified and works in every evergreen browser.

Step 1 – Structure the HTML

The gallery needs a wrapper, a primary display area for the selected image, and a strip of thumbnails. We will keep the DOM flat and rely on data- attributes* so the JavaScript can find elements without class name gymnastics.

Code
<section class="gallery" aria-label="Featured photography">
  <figure class="gallery__stage">
    <img src="images/lake.jpg" alt="Mountain lake at sunrise" loading="lazy">
    <figcaption>Mountain lake at sunrise</figcaption>
  </figure>
  <ul class="gallery__thumbs">
    <li>
      <button type="button" data-image="images/lake.jpg" data-caption="Mountain lake at sunrise" aria-current="true">
        <img src="images/lake-thumb.jpg" alt="Mountain lake at sunrise">
      </button>
    </li>
    <li>
      <button type="button" data-image="images/forest.jpg" data-caption="Misty forest canopy">
        <img src="images/forest-thumb.jpg" alt="Misty forest canopy">
      </button>
    </li>
    <li>
      <button type="button" data-image="images/desert.jpg" data-caption="Desert dunes at golden hour">
        <img src="images/desert-thumb.jpg" alt="Desert dunes at golden hour">
      </button>
    </li>
  </ul>
</section>

Accessibility tips

  • Wrap the main image in a <figure> so that its caption is associated automatically.
  • Use <button> elements for the thumbnails instead of links; they are focusable, respond to keyboard events, and do not trigger page navigation.
  • Mark the active thumbnail with aria-current="true" so assistive technology can announce the current selection.

Step 2 – Add minimal CSS

The layout hinges on modern CSS features supported since 2020. We will use grid for the overall structure and aspect-ratio to keep the thumbnails tidy.

Code
.gallery {
  --gap: clamp(0.75rem, 2vw, 1.5rem);
  display: grid;
  gap: var(--gap);
}

.gallery__stage {
  margin: 0;
  position: relative;
  border-radius: 16px;
  overflow: hidden;
  box-shadow: 0 10px 40px rgba(15, 23, 42, 0.12);
}

.gallery__stage img {
  width: 100%;
  height: auto;
  display: block;
}

.gallery__stage figcaption {
  position: absolute;
  inset: auto 0 0 0;
  padding: 0.75rem 1rem;
  background: linear-gradient(transparent, rgba(15, 23, 42, 0.72));
  color: white;
  font-size: 0.95rem;
}

.gallery__thumbs {
  list-style: none;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
  gap: var(--gap);
  padding: 0;
  margin: 0;
}

.gallery__thumbs button {
  border: 0;
  padding: 0;
  background: none;
  border-radius: 12px;
  overflow: hidden;
  cursor: pointer;
  position: relative;
  transition: transform 160ms ease, box-shadow 160ms ease;
}

.gallery__thumbs button[aria-current="true"] {
  box-shadow: 0 0 0 3px #2563eb;
}

.gallery__thumbs button:focus-visible {
  outline: 3px solid #f59e0b;
  outline-offset: 2px;
}

.gallery__thumbs img {
  width: 100%;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  display: block;
}

@media (min-width: 720px) {
  .gallery {
    grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
    align-items: start;
  }
}

The CSS keeps the component flexible and removes default button styling that would otherwise clash with the design. The responsive breakpoint turns the thumbnail strip into a column on larger screens.

Step 3 – Wire up native JavaScript

The JavaScript required for this gallery fits in 35 lines. The script grabs references to the stage image, caption, and all thumbnail buttons, then listens for click and keyboard events. When a thumbnail is activated, the script swaps the src and caption and updates the active state.

Code
const gallery = document.querySelector('.gallery');
if (!gallery) {
  throw new Error('Gallery root not found.');
}

const stageImage = gallery.querySelector('.gallery__stage img');
const stageCaption = gallery.querySelector('.gallery__stage figcaption');
const thumbButtons = gallery.querySelectorAll('.gallery__thumbs button');

function activate(button) {
  const image = button.dataset.image;
  const caption = button.dataset.caption;

  stageImage.src = image;
  stageImage.alt = caption;
  stageCaption.textContent = caption;

  thumbButtons.forEach((btn) => btn.removeAttribute('aria-current'));
  button.setAttribute('aria-current', 'true');
}

thumbButtons.forEach((button) => {
  button.addEventListener('click', () => activate(button));
  button.addEventListener('keydown', (event) => {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      activate(button);
    }
  });
});

Why no global event delegation? The gallery is small, so iterating over the handful of thumbnails keeps the code readable. If you have hundreds of thumbnails, switch to a delegated listener on the <ul> and inspect event.target.closest('button').

Step 4 – Progressive enhancements

The base gallery already works, but we can sprinkle a few improvements without increasing complexity:

  • Lazy load thumbnails: add loading="lazy" to the thumbnail <img> elements so browsers defer offscreen requests.
  • Preload full-size images: if you are concerned about a delay when switching, include <link rel="preload" as="image"> tags for the stage images.
  • Animate the stage transition: fade between images with a CSS transition on opacity. One approach is to toggle a class before updating src, wait for transitionend, then switch images.
  • Keyboard navigation between thumbnails: add arrow key handling to move focus between buttons. The markup already groups the buttons in a list, so ArrowLeft, ArrowRight, ArrowUp, and ArrowDown logic is straightforward.
  • Touch-friendly hit area: increase the thumbnail button padding or use ::after to extend the tap target without affecting layout.

Putting it all together

Below is the complete gallery, combining the HTML, CSS, and JavaScript from the earlier steps. You can drop this snippet into any static site and adapt the image paths to your assets.

Code
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Minimal Native JS Gallery</title>
    <style>
      /* CSS from Step 2 */
      .gallery {
        --gap: clamp(0.75rem, 2vw, 1.5rem);
        display: grid;
        gap: var(--gap);
      }
      .gallery__stage {
        margin: 0;
        position: relative;
        border-radius: 16px;
        overflow: hidden;
        box-shadow: 0 10px 40px rgba(15, 23, 42, 0.12);
      }
      .gallery__stage img {
        width: 100%;
        display: block;
      }
      .gallery__stage figcaption {
        position: absolute;
        inset: auto 0 0 0;
        padding: 0.75rem 1rem;
        background: linear-gradient(transparent, rgba(15, 23, 42, 0.72));
        color: white;
        font-size: 0.95rem;
      }
      .gallery__thumbs {
        list-style: none;
        display: grid;
        grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
        gap: var(--gap);
        padding: 0;
        margin: 0;
      }
      .gallery__thumbs button {
        border: 0;
        padding: 0;
        background: none;
        border-radius: 12px;
        overflow: hidden;
        cursor: pointer;
        transition: transform 160ms ease, box-shadow 160ms ease;
      }
      .gallery__thumbs button:hover {
        transform: translateY(-4px);
        box-shadow: 0 12px 30px rgba(15, 23, 42, 0.18);
      }
      .gallery__thumbs button[aria-current="true"] {
        box-shadow: 0 0 0 3px #2563eb;
      }
      .gallery__thumbs button:focus-visible {
        outline: 3px solid #f59e0b;
        outline-offset: 2px;
      }
      .gallery__thumbs img {
        width: 100%;
        aspect-ratio: 4 / 3;
        object-fit: cover;
        display: block;
      }
      @media (min-width: 720px) {
        .gallery {
          grid-template-columns: minmax(0, 3fr) minmax(0, 2fr);
          align-items: start;
        }
      }
    </style>
  </head>
  <body>
    <section class="gallery" aria-label="Featured photography">
      <figure class="gallery__stage">
        <img src="images/lake.jpg" alt="Mountain lake at sunrise" loading="lazy">
        <figcaption>Mountain lake at sunrise</figcaption>
      </figure>
      <ul class="gallery__thumbs">
        <li>
          <button type="button" data-image="images/lake.jpg" data-caption="Mountain lake at sunrise" aria-current="true">
            <img src="images/lake-thumb.jpg" alt="Mountain lake at sunrise" loading="lazy">
          </button>
        </li>
        <li>
          <button type="button" data-image="images/forest.jpg" data-caption="Misty forest canopy">
            <img src="images/forest-thumb.jpg" alt="Misty forest canopy" loading="lazy">
          </button>
        </li>
        <li>
          <button type="button" data-image="images/desert.jpg" data-caption="Desert dunes at golden hour">
            <img src="images/desert-thumb.jpg" alt="Desert dunes at golden hour" loading="lazy">
          </button>
        </li>
      </ul>
    </section>

    <script>
      const gallery = document.querySelector('.gallery');
      const stageImage = gallery.querySelector('.gallery__stage img');
      const stageCaption = gallery.querySelector('.gallery__stage figcaption');
      const thumbButtons = gallery.querySelectorAll('.gallery__thumbs button');

      function activate(button) {
        stageImage.src = button.dataset.image;
        stageImage.alt = button.dataset.caption;
        stageCaption.textContent = button.dataset.caption;

        thumbButtons.forEach((btn) => btn.removeAttribute('aria-current'));
        button.setAttribute('aria-current', 'true');
      }

      thumbButtons.forEach((button) => {
        button.addEventListener('click', () => activate(button));
        button.addEventListener('keydown', (event) => {
          if (event.key === 'Enter' || event.key === ' ') {
            event.preventDefault();
            activate(button);
          }
        });
      });
    </script>
  </body>
</html>

Next steps

  • Swap the static image list for a JSON feed or CMS output by looping through an array and generating the same markup on the server.
  • Wrap the gallery in a custom element so you can drop it into multiple pages with a single import.
  • Add history integration (e.g., update the URL hash) if you want deep links to specific images.
  • Combine this gallery with the Intersection Observer API to lazy-load entire sections when the user scrolls.

This tutorial shows that you can deliver a polished gallery with native browser features and very little code. Feel free to extend the component, but remember that clarity and accessibility are features. Minimalism is a choice, not a compromise.