Product Gallery

@use 'sprucecss/scss/spruce' as *;

.product-gallery {
  @include layout-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;
    }

    @include motion-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;
    @include transition($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;
}
<div class="product-gallery" data-product-gallery>
    <div class="product-gallery__stage">
        <div class="product-gallery__main" data-gallery-zoom>
            <img class="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>
        <button class="product-gallery__arrow product-gallery__arrow--previous" type="button" data-gallery-step="-1" hidden>
            <svg xmlns="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"><path d="m15 18-6-6 6-6"/></svg>
            <span class="sr-only">Previous image</span>
        </button>
        <button class="product-gallery__arrow product-gallery__arrow--next" type="button" data-gallery-step="1" hidden>
            <svg xmlns="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"><path d="m9 18 6-6-6-6"/></svg>
            <span class="sr-only">Next image</span>
        </button>
        <p class="product-gallery__counter" aria-hidden="true" data-gallery-counter hidden></p>
        <p class="sr-only" aria-live="polite" data-gallery-live></p>
    </div>
    <ul class="product-gallery__thumbs" role="list" aria-label="Product images">
        <li>
            <a class="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-thumb data-alt="Heritage Backpack in forest green with brown leather straps, front view.">
                <img src="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>
            <a class="product-gallery__thumb" href="https://images.unsplash.com/photo-1491637639811-60e2756cc1c7?auto=format&fit=crop&w=1200&h=1200&q=75" data-gallery-thumb data-alt="The Heritage Backpack held up against a mountain landscape.">
                <img src="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>
            <a class="product-gallery__thumb" href="https://images.unsplash.com/photo-1622560480605-d83c853bc5c3?auto=format&fit=crop&w=1200&h=1200&q=75" data-gallery-thumb data-alt="The Heritage Backpack in cognac leather.">
                <img src="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>
            <a class="product-gallery__thumb" href="https://images.unsplash.com/photo-1622560481156-01fc7e1693e6?auto=format&fit=crop&w=1200&h=1200&q=75" data-gallery-thumb data-alt="The Heritage Backpack in black and in burgundy, side by side.">
                <img src="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'));

    const show = (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

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.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.