Product gallery with thumbnails
A product image gallery with a large view, a thumbnail strip, arrow key navigation and an accessible selected state.
Tailwind CSSCardsgallerythumbnailsproductimages
Live preview
Code
<div id="gallery" class="mx-auto max-w-md">
<div class="aspect-square overflow-hidden rounded-xl border border-slate-200 bg-slate-50">
<img id="gallery-main" src="/img/bag-front-800.jpg" alt="Canvas bag, front view"
class="size-full object-contain transition-opacity duration-150">
</div>
<div class="mt-3 flex snap-x gap-2 overflow-x-auto pb-1" role="group" aria-label="Product images">
<button type="button" aria-current="true" data-full="/img/bag-front-800.jpg" data-alt="Canvas bag, front view"
class="size-16 shrink-0 snap-start overflow-hidden rounded-lg border-2 border-transparent aria-[current=true]:border-indigo-600">
<img src="/img/bag-front-120.jpg" alt="" class="size-full object-cover">
<span class="sr-only">Front view</span>
</button>
<button type="button" data-full="/img/bag-side-800.jpg" data-alt="Canvas bag, side view"
class="size-16 shrink-0 snap-start overflow-hidden rounded-lg border-2 border-transparent aria-[current=true]:border-indigo-600">
<img src="/img/bag-side-120.jpg" alt="" class="size-full object-cover">
<span class="sr-only">Side view</span>
</button>
<button type="button" data-full="/img/bag-inside-800.jpg" data-alt="Canvas bag, open interior"
class="size-16 shrink-0 snap-start overflow-hidden rounded-lg border-2 border-transparent aria-[current=true]:border-indigo-600">
<img src="/img/bag-inside-120.jpg" alt="" class="size-full object-cover">
<span class="sr-only">Interior</span>
</button>
</div>
</div>
const main = document.getElementById('gallery-main');
const thumbs = [...document.querySelectorAll('#gallery button[data-full]')];
function show(i, moveFocus = false) {
const t = thumbs[(i + thumbs.length) % thumbs.length];
main.classList.add('opacity-0'); // quick fade between pictures
main.addEventListener('load', () => main.classList.remove('opacity-0'), { once: true });
main.src = t.dataset.full;
main.alt = t.dataset.alt; // keep the description in sync
thumbs.forEach((b) => b.setAttribute('aria-current', String(b === t)));
if (moveFocus) t.focus();
}
thumbs.forEach((t, i) => {
t.addEventListener('click', () => show(i));
t.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight') { e.preventDefault(); show(i + 1, true); }
if (e.key === 'ArrowLeft') { e.preventDefault(); show(i - 1, true); }
});
});
How it works
What it solves
Shoppers want to inspect a product from several angles. A gallery needs one large picture, small selectors, and behaviour that works from the keyboard. This pattern swaps a single img rather than stacking slides, which keeps it light.
How it works
Thumbnails are buttons inside a labelled role="group": each button wraps an img and carries data-full and data-alt attributes. A click copies those values to the main image, sets aria-current="true" on the chosen thumbnail and clears it from the rest. The main image sits in a fixed aspect-square frame with object-contain, so changing pictures never shifts the layout. Left and Right arrow keys choose the previous or next item and focus it. A short opacity fade runs on swap through a class toggled for one frame. The strip scrolls horizontally with overflow-x-auto and snap-x on narrow screens.
Adapting it
Add loading="lazy" to thumbnails and preload the first large image. For zoom, wrap the main image in a container with overflow-hidden and scale on hover. Keep thumbnails to 4 to 6 so the strip stays scannable.
Pitfalls
- Always update
altalong withsrc, or assistive tech reads the old description. - Thumbnails must be buttons, not clickable images.
- Reserve the main frame size to avoid layout shift.
How to use it
- Paste
gallery.htmlinto your product page and replace the image URLs; the thumbnails can reuse smaller versions. - Keep each button's
data-fullanddata-altin sync with its picture. - Load
gallery.jswithdefer. - Mark the first thumbnail with
aria-current="true"in the markup for the initial state.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.