Gallery

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

.gallery-block {
  padding-block: spacer-clamp('xl', 'xxl');

  &__inner {
    display: flex;
    flex-direction: column;
    gap: spacer('l');
  }

  &__grid {
    @include clear-list;
    columns: 3 16rem;
    gap: spacer('m');

    li {
      break-inside: avoid;
      margin: 0 0 spacer('m') !important;
    }
  }

  &__item {
    border-radius: radius('lg');
    display: block;
    overflow: hidden;
    position: relative;

    img {
      inline-size: 100%;
      @include transition($property: 'scale');
    }

    &:hover img {
      @include motion-safe {
        scale: 1.04;
      }
    }

    &:focus-visible {
      outline: 2px solid color('focus-ring');
      outline-offset: 3px;
    }
  }

  &__caption {
    background-image: linear-gradient(to top, hsl(0deg 0% 0% / 65%), transparent);
    color: hsl(0deg 0% 100%);
    font-weight: 700;
    inset-block-end: 0;
    inset-inline: 0;
    padding: spacer('l') spacer('s') spacer('s');
    position: absolute;
  }

  &__lightbox {
    background: transparent;
    block-size: 100dvb;
    border: 0;
    box-shadow: none;
    inline-size: 100vi;
    max-block-size: none;
    max-inline-size: none;
    padding: spacer('s');

    &[open] {
      align-items: center;
      display: grid;
      gap: spacer('xs') spacer-clamp('xs', 'm');
      grid-template-areas:
        '. . close'
        'prev figure next'
        '. counter .';
      grid-template-columns: auto minmax(0, 1fr) auto;
      grid-template-rows: auto minmax(0, 1fr) auto;
    }

    &::backdrop {
      background-color: hsl(0deg 0% 0% / 85%);
    }
  }

  &__close {
    grid-area: close;
  }

  &__nav {
    &--prev {
      grid-area: prev;
    }

    &--next {
      grid-area: next;
    }
  }

  &__figure {
    align-self: stretch;
    display: flex;
    flex-direction: column;
    grid-area: figure;
    justify-content: center;
    margin: 0;
    min-block-size: 0;

    img {
      border-radius: radius('md');
      margin-inline: auto;
      max-block-size: 100%;
      min-block-size: 0;
      object-fit: contain;
    }

    figcaption {
      color: hsl(0deg 0% 100%);
      margin-block-start: spacer('xs');
      text-align: center;
    }
  }

  &__counter {
    color: hsl(0deg 0% 100%);
    font-size: config('font-size-sm', $typography);
    grid-area: counter;
    text-align: center;
  }
}

// The arrows point the reading direction.
[dir='rtl'] .gallery-block__nav svg {
  scale: -1 1;
}
<section class="gallery-block" aria-labelledby="gallery-title" data-gallery>
    <div class="container">
        <div class="gallery-block__inner">
            <header class="block-heading">
                <p class="block-heading__eyebrow">Gallery</p>
                <h2 class="block-heading__title" id="gallery-title">Behind the scenes</h2>
                <p class="block-heading__text">A look at our studio, our workshops and our work.</p>
            </header>
            <ul class="gallery-block__grid" role="list">
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Studio">
                        <img src="https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="A bright studio with plants." loading="lazy">
                        <span class="gallery-block__caption">Studio</span>
                    </a>
                </li>
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Team">
                        <img src="https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="A team meeting around a table." loading="lazy">
                        <span class="gallery-block__caption">Team</span>
                    </a>
                </li>
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Code">
                        <img src="https://images.unsplash.com/photo-1498050108023-c5249f4df085?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="A laptop with code." loading="lazy">
                        <span class="gallery-block__caption">Code</span>
                    </a>
                </li>
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1561070791-2526d30994b5?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Color">
                        <img src="https://images.unsplash.com/photo-1561070791-2526d30994b5?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="Color swatches." loading="lazy">
                        <span class="gallery-block__caption">Color</span>
                    </a>
                </li>
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1531403009284-440f080d1e12?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Workshop">
                        <img src="https://images.unsplash.com/photo-1531403009284-440f080d1e12?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="A workshop in progress." loading="lazy">
                        <span class="gallery-block__caption">Workshop</span>
                    </a>
                </li>
                <li>
                    <a class="gallery-block__item" href="https://images.unsplash.com/photo-1558655146-d09347e92766?auto=format&amp;fit=crop&amp;w=1600&amp;q=70" data-gallery-item data-caption="Interface">
                        <img src="https://images.unsplash.com/photo-1558655146-d09347e92766?auto=format&amp;fit=crop&amp;w=700&amp;q=70" alt="Interface sketches on a screen." loading="lazy">
                        <span class="gallery-block__caption">Interface</span>
                    </a>
                </li>
            </ul>
        </div>
    </div>
    <dialog class="gallery-block__lightbox" aria-label="Image viewer" data-gallery-dialog>
        <button class="btn btn--secondary btn--icon btn--sm gallery-block__close" type="button" aria-label="Close" data-gallery-close><svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
        <button class="btn btn--secondary btn--icon gallery-block__nav gallery-block__nav--prev" type="button" aria-label="Previous image" data-gallery-prev><svg class="btn__icon" 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></button>
        <figure class="gallery-block__figure">
            <img src="" alt="" data-gallery-image>
            <figcaption data-gallery-caption hidden></figcaption>
        </figure>
        <button class="btn btn--secondary btn--icon gallery-block__nav gallery-block__nav--next" type="button" aria-label="Next image" data-gallery-next><svg class="btn__icon" 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></button>
        <span class="gallery-block__counter" aria-live="polite" data-gallery-counter></span>
    </dialog>
</section>
(() => {
  document.querySelectorAll('[data-gallery]').forEach((gallery) => {
    const links = [...gallery.querySelectorAll('[data-gallery-item]')];
    const dialog = gallery.querySelector('[data-gallery-dialog]');
    const image = dialog.querySelector('[data-gallery-image]');
    const caption = dialog.querySelector('[data-gallery-caption]');
    const counter = dialog.querySelector('[data-gallery-counter]');
    let current = 0;

    const show = (index) => {
      current = (index + links.length) % links.length;
      const link = links[current];
      const thumbnail = link.querySelector('img');

      image.src = link.href;
      image.alt = thumbnail.alt;
      caption.textContent = link.dataset.caption || '';
      caption.hidden = !link.dataset.caption;
      counter.textContent = `${current + 1} of ${links.length}`;
    };

    links.forEach((link, index) => {
      link.addEventListener('click', (event) => {
        event.preventDefault();
        show(index);
        dialog.showModal();
      });
    });

    dialog.querySelector('[data-gallery-prev]').addEventListener('click', () => show(current - 1));
    dialog.querySelector('[data-gallery-next]').addEventListener('click', () => show(current + 1));
    dialog.querySelector('[data-gallery-close]').addEventListener('click', () => dialog.close());

    dialog.addEventListener('click', (event) => {
      if (event.target === dialog) dialog.close();
    });

    dialog.addEventListener('keydown', (event) => {
      const isRtl = getComputedStyle(gallery).direction === 'rtl';

      if (event.key === 'ArrowRight') show(current + (isRtl ? -1 : 1));
      if (event.key === 'ArrowLeft') show(current + (isRtl ? 1 : -1));
    });
  });
})();

Gallery

On this page

A masonry-style image gallery with captions and an accessible lightbox.

Technical Details

  • The grid uses CSS multi-columns (columns: 3 16rem), so images keep their own proportions.
  • Every image is a link to the full-size version: without JavaScript, it simply opens the image.
  • The lightbox is a native dialog opened with showModal(): focus trapping, Esc to close and focus return come from the browser. ←/→ (mirrored in RTL) and the buttons move between images, and a live region announces "3 of 6". A click on the backdrop closes it.
  • Override the content with galleryBlock (image is the thumbnail, full the large version).

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.