@use'sprucecss/scss/spruce'as *;.product-gallery {@includelayout-stack('s');&__stage {position: relative;}&__main {aspect-ratio: 1;background-color:color('surface-muted');border-radius:radius('lg');overflow: hidden;}&__image {block-size: 100%;inline-size: 100%;object-fit: cover;transform-origin:var(--zoom-x, 50%)var(--zoom-y, 50%);}@media(hover: hover)and(pointer: fine){&__main {cursor: zoom-in;}@includemotion-safe {&__image {transition: scale 0.3s ease-out;}}&__main:hover &__image {scale: 1.75;}}&__arrow {@include clear-btn;align-items: center;aspect-ratio: 1;background-color:color-alpha('surface', 0.9);border:config('border-width-base',$display) solid color('border');border-radius:radius('pill');box-shadow:shadow('sm');color:color('heading');display: inline-flex;inline-size: 2.5rem;inset-block-start: 50%;justify-content: center;position: absolute;translate: 0 -50%;@include transition;svg {block-size: 1.25rem;inline-size: 1.25rem;}&:hover {background-color:color('surface');}&:focus-visible {outline: 2px solid color('focus-ring');outline-offset: 2px;}&--previous {inset-inline-start:spacer('s');}&--next {inset-inline-end:spacer('s');}}&__counter {background-color:color-alpha('surface', 0.9);border-radius:radius('pill');color:color('heading');font-size: 0.75rem;font-variant-numeric: tabular-nums;font-weight: 700;inset-block-end:spacer('s');inset-inline-end:spacer('s');margin: 0;padding: 0.25em 0.75em;position: absolute;}&__thumbs {display: grid;gap:spacer('xs');grid-template-columns:repeat(auto-fill,minmax(4rem, 1fr));// The parent's layout-stack sets the margins.list-style: none;padding: 0;li {margin: 0;}// The items sit side by side: clear the stack margins of the base list style.> li {margin-block: 0;padding-block: 0;}}&__thumb {border-radius:radius('md');display: block;outline: 2px solid transparent;outline-offset: 2px;overflow: hidden;@includetransition($property:'opacity, outline-color');img {aspect-ratio: 1;inline-size: 100%;object-fit: cover;}
&:not([aria-current]){opacity: 0.7;}&:hover {opacity: 1;}&[aria-current] {outline-color:color('primary');}&:focus-visible {outline-color:color('focus-ring');}}}[dir='rtl'] .product-gallery__arrow svg {scale: -1 1;}
<divclass="product-gallery"data-product-gallery><divclass="product-gallery__stage"><divclass="product-gallery__main"data-gallery-zoom><imgclass="product-gallery__image"src="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=1200&h=1200&q=75"alt="Heritage Backpack in forest green with brown leather straps, front view."width="1200"height="1200"fetchpriority="high"data-gallery-image></div><buttonclass="product-gallery__arrow product-gallery__arrow--previous"type="button"data-gallery-step="-1"hidden><svgxmlns="http://www.w3.org/2000/svg"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"focusable="false"><pathd="m15 18-6-6 6-6"/></svg><spanclass="sr-only">Previous image</span></button><buttonclass="product-gallery__arrow product-gallery__arrow--next"type="button"data-gallery-step="1"hidden><svgxmlns="http://www.w3.org/2000/svg"viewBox="0 0 24 24"fill="none"stroke="currentColor"stroke-width="2"stroke-linecap="round"stroke-linejoin="round"aria-hidden="true"focusable="false"><pathd="m9 18 6-6-6-6"/></svg><spanclass="sr-only">Next image</span></button><pclass="product-gallery__counter"aria-hidden="true"data-gallery-counterhidden></p><pclass="sr-only"aria-live="polite"data-gallery-live></p></div><ulclass="product-gallery__thumbs"role="list"aria-label="Product images"><li><aclass="product-gallery__thumb"href="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=1200&h=1200&q=75"aria-current="true"data-gallery-thumbdata-alt="Heritage Backpack in forest green with brown leather straps, front view."><imgsrc="https://images.unsplash.com/photo-1577733966973-d680bffd2e80?auto=format&fit=crop&w=160&h=160&q=60"alt="Image 1: Front"width="80"height="80"loading="lazy"></a></li><li><aclass="product-gallery__thumb"href="https://images.unsplash.com/photo-1491637639811-60e2756cc1c7?auto=format&fit=crop&w=1200&h=1200&q=75"data-gallery-thumbdata-alt="The Heritage Backpack held up against a mountain landscape."><imgsrc="https://images.unsplash.com/photo-1491637639811-60e2756cc1c7?auto=format&fit=crop&w=160&h=160&q=60"alt="Image 2: Outdoors"width="80"height="80"loading="lazy"></a></li><li><aclass="product-gallery__thumb"href="https://images.unsplash.com/photo-1622560480605-d83c853bc5c3?auto=format&fit=crop&w=1200&h=1200&q=75"data-gallery-thumbdata-alt="The Heritage Backpack in cognac leather."><imgsrc="https://images.unsplash.com/photo-1622560480605-d83c853bc5c3?auto=format&fit=crop&w=160&h=160&q=60"alt="Image 3: Cognac"width="80"height="80"loading="lazy"></a></li><li><aclass="product-gallery__thumb"href="https://images.unsplash.com/photo-1622560481156-01fc7e1693e6?auto=format&fit=crop&w=1200&h=1200&q=75"data-gallery-thumbdata-alt="The Heritage Backpack in black and in burgundy, side by side."><imgsrc="https://images.unsplash.com/photo-1622560481156-01fc7e1693e6?auto=format&fit=crop&w=160&h=160&q=60"alt="Image 4: Black and burgundy"width="80"height="80"loading="lazy"></a></li></ul></div>
(()=>{
document.querySelectorAll('[data-product-gallery]').forEach((gallery)=>{const image = gallery.querySelector('[data-gallery-image]');const thumbs =[...gallery.querySelectorAll('[data-gallery-thumb]')];const counter = gallery.querySelector('[data-gallery-counter]');const live = gallery.querySelector('[data-gallery-live]');const zoom = gallery.querySelector('[data-gallery-zoom]');let current = thumbs.findIndex((thumb)=> thumb.hasAttribute('aria-current'));constshow=(index, announce =true)=>{
current =(index + thumbs.length)% thumbs.length;const thumb = thumbs[current];
thumbs.forEach((item)=> item.removeAttribute('aria-current'));
thumb.setAttribute('aria-current','true');
image.src = thumb.href;
image.alt = thumb.dataset.alt;
counter.textContent =`${current +1} / ${thumbs.length}`;if(announce){
live.textContent =`Image ${current +1} of ${thumbs.length}: ${thumb.dataset.alt}`;}};// Without JavaScript, the thumbnails are links to the large images.
thumbs.forEach((thumb, index)=>{
thumb.addEventListener('click',(event)=>{
event.preventDefault();show(index);});});
gallery.querySelectorAll('[data-gallery-step]').forEach((button)=>{
button.hidden =false;
button.addEventListener('click',()=>show(current +Number(button.dataset.galleryStep)));});
gallery.addEventListener('keydown',(event)=>{if(!event.target.closest('[data-gallery-thumb], [data-gallery-step]')){return;}const isRtl =getComputedStyle(gallery).direction ==='rtl';const step ={ArrowLeft: isRtl ?1:-1,ArrowRight: isRtl ?-1:1}[event.key];if(step && event.target.closest('[data-gallery-thumb]')){
event.preventDefault();show(current + step);
thumbs[current].focus();}});// Hover zoom follows the pointer (only for a mouse or a trackpad; see the CSS).
zoom.addEventListener('pointermove',(event)=>{const box = zoom.getBoundingClientRect();
zoom.style.setProperty('--zoom-x',`${((event.clientX - box.left)/ box.width)*100}%`);
zoom.style.setProperty('--zoom-y',`${((event.clientY - box.top)/ box.height)*100}%`);});
counter.hidden =false;show(current <0?0: current,false);});})();
Product Gallery
On this page
The image gallery of a product page: a large image with hover zoom, previous and next buttons, a counter and a row of thumbnails.
Anatomy
.product-gallery__main: the large image. The zoom origin follows the pointer through the --zoom-x and --zoom-y custom properties.
.product-gallery__arrow: the previous (--previous) and next (--next) buttons, and the __counter.
.product-gallery__thumbs: a list of thumbnail links to the large images; the current one has aria-current="true".
Accessibility and Usability
The thumbnails are links to the large images. Without JavaScript, they open the photo; with JavaScript, they swap the main image.
Every image has its own description: the thumbnails are numbered ("Image 2: Outdoors"), and the large image gets the long description of the selected one.
A polite live region announces the switch: "Image 2 of 4" and the description. The visual counter ("2 / 4") is hidden from screen readers.
The arrow keys move between the images when a thumbnail has the focus; they follow the reading direction in right-to-left languages, and the arrow icons are mirrored.
The hover zoom only works with a precise pointer (hover: hover and pointer: fine), so it never gets in the way of scrolling on touch screens, and it doesn't animate with prefers-reduced-motion: reduce.
Technical Details
The main image has fetchpriority="high" because it's usually the largest element of the page (the Largest Contentful Paint); the thumbnails are loading="lazy".
Serve the large images at about twice the displayed size, so the 1.75× zoom stays sharp.
Combine it with the Product Details in a two-column product page.
Documentation
Learn about Spruce CSS through our extensive documentation.