Docs
Next

Gallery

Swiper-based galleries: Gallery, GalleryContent, GalleryWrapper, GalleryItem.

On this page

Gallery is a full-width section with an optional intro and a horizontally scrolling row of images. Slides keep their own width at a fixed height, so mixed portrait and landscape images sit side by side. Below the row, a counter and two buttons navigate it. The slider is Swiper (opens in new tab), loaded only on pages that have a gallery.

import {
  Gallery,
  GalleryContent,
  GalleryItem,
  GalleryWrapper,
  Heading,
  Image,
  Paragraph,
} from '@systhemaui/next'

const slides = [
  { src: '/demo-assets/coast.webp', width: 1800, height: 1200, alt: 'A rocky coastline at dusk' },
  { src: '/demo-assets/dunes-portrait.webp', width: 1200, height: 1600, alt: 'Sand dunes' },
  { src: '/demo-assets/workspace.webp', width: 1800, height: 1200, alt: 'A tidy desk' },
  { src: '/demo-assets/forest-portrait.webp', width: 1200, height: 1600, alt: 'A pine forest' },
  { src: '/demo-assets/mountains.webp', width: 1920, height: 1080, alt: 'Mountain ridges' },
]

export default function Demo() {
  return (
    <Gallery>
      <GalleryContent>
        <Heading.h2>Field notes</Heading.h2>
        <Paragraph type="lead">Photos from the spring campaign. Drag, scroll or use the arrows.</Paragraph>
      </GalleryContent>
      <GalleryWrapper>
        {slides.map((slide) => (
          <GalleryItem key={slide.src}>
            <Image src={slide.src} alt={slide.alt} width={slide.width} height={slide.height} sizes="(min-width: 768px) 40vw, 80vw" />
          </GalleryItem>
        ))}
      </GalleryWrapper>
    </Gallery>
  )
}

ImportLink to this section

import { Gallery, GalleryContent, GalleryItem, GalleryWrapper } from '@systhemaui/next'

In a React app without Next.js, import them from @systhemaui/react. The API is the same.

PartsLink to this section

PartRenders
Gallerya <section class="py-section gallery"> with optional theme and layoutBackground
GalleryContentthe intro: a grid container with a richtext column, inset by padding grid columns (0 to 6)
GalleryWrapperthe slider on the same grid, inset by padding, with the counter and the navigation buttons under it
GalleryItemone slide; its child is set to the slide height with an automatic width

The slider runs in free mode on small screens and snaps to slides from the md breakpoint. It answers the keyboard (arrow keys while it is in the viewport), horizontal mouse-wheel and trackpad gestures, and dragging. Slide height, slide gap per breakpoint and every navigation color come from the gallery tokens.

ExamplesLink to this section

Theme and backgroundLink to this section

theme switches the section to a color-system mode (default or dark with the default tokens), and layoutBackground paints it with a layout background (main or alternative). See Themes.

import { Gallery, GalleryContent, GalleryItem, GalleryWrapper, Heading, Image } from '@systhemaui/next'

const slides = [
  { src: '/demo-assets/city.webp', width: 1920, height: 1080 },
  { src: '/demo-assets/coast.webp', width: 1800, height: 1200 },
  { src: '/demo-assets/workspace.webp', width: 1800, height: 1200 },
  { src: '/demo-assets/mountains.webp', width: 1920, height: 1080 },
]

export default function Demo() {
  return (
    <Gallery theme="dark" layoutBackground="main">
      <GalleryContent>
        <Heading.h2>After dark</Heading.h2>
      </GalleryContent>
      <GalleryWrapper>
        {slides.map((slide) => (
          <GalleryItem key={slide.src}>
            <Image src={slide.src} alt="" width={slide.width} height={slide.height} sizes="50vw" />
          </GalleryItem>
        ))}
      </GalleryWrapper>
    </Gallery>
  )
}

Inset from the edgesLink to this section

padding on GalleryContent and GalleryWrapper insets each from both sides by that many grid columns (col-mx-0 to col-mx-6). Use the same value on both to align the intro with the slides.

import {
  Gallery,
  GalleryContent,
  GalleryItem,
  GalleryWrapper,
  Heading,
  Image,
} from '@systhemaui/next'

export default function Demo() {
  return (
    <Gallery layoutBackground="alternative">
      <GalleryContent padding={2}>
        <Heading.h3>Inset by two columns</Heading.h3>
      </GalleryContent>
      <GalleryWrapper padding={2}>
        <GalleryItem>
          <Image src="/demo-assets/abstract-square.webp" alt="" width={1200} height={1200} sizes="40vw" />
        </GalleryItem>
        <GalleryItem>
          <Image src="/demo-assets/forest-portrait.webp" alt="" width={1200} height={1600} sizes="40vw" />
        </GalleryItem>
        <GalleryItem>
          <Image src="/demo-assets/coast.webp" alt="" width={1800} height={1200} sizes="40vw" />
        </GalleryItem>
      </GalleryWrapper>
    </Gallery>
  )
}

Translated button labelsLink to this section

The navigation buttons are labelled "Previous slide" and "Next slide". Pass labels to GalleryWrapper to translate them.

import { GalleryItem, GalleryWrapper, Image } from '@systhemaui/next'

export function GalerieFotos({ photos }: { photos: { src: string; alt: string }[] }) {
  return (
    <GalleryWrapper labels={{ previousSlide: 'Vorheriges Bild', nextSlide: 'Nächstes Bild' }}>
      {photos.map((photo) => (
        <GalleryItem key={photo.src}>
          <Image src={photo.src} alt={photo.alt} width={1800} height={1200} />
        </GalleryItem>
      ))}
    </GalleryWrapper>
  )
}

GalleryWrapper also takes className, which is added to every slide, and disableAnimation, which drops the configured animationClasses.

LoadingLink to this section

GalleryWrapper is a client component, and Swiper (about 120 KB) sits behind a lazy import in its own chunk. Until the chunk arrives, the slides render as a static row with the same .swiper markup and layout, and the server render already contains the real markup. See Swiper is loaded on demand.

PropsLink to this section

Gallery takes every <section> attribute.

PropTypeDefaultDescription
themeColorSystem (dark, default)-
layoutBackgroundLayoutBackground (main, alternative)-
…and all <section> attributes

GalleryContentLink to this section

GalleryContent takes every <div> attribute; padding defaults to 0.

PropTypeDefaultDescription
paddingnumber0
…and all <div> attributes

HTML and CSSLink to this section

The section and its navigation are plain classes. The slider itself needs Swiper (or the markup below, which renders as a static row):

<section class="py-section gallery bg-layout-alternative">
  <div class="grid-default container">
    <div class="richtext col-mx-0">
      <h2 class="text-h2 color-heading">Field notes</h2>
    </div>
  </div>
  <div class="grid-default container">
    <div class="gallery-wrapper col-mx-0">
      <div class="swiper" id="gallery-1-swiper">
        <div class="swiper-wrapper">
          <div class="swiper-slide"><img class="media" src="/coast.webp" alt="" /></div>
          <div class="swiper-slide"><img class="media" src="/dunes.webp" alt="" /></div>
        </div>
      </div>
      <div class="gallery-navigation">
        <div class="gallery-navigation-pagination" role="status">1 / 2</div>
        <div class="gallery-navigation-buttons">
          <button class="gallery-navigation-button-prev" aria-controls="gallery-1-swiper">
            <span class="gallery-navigation-button-icon gallery-navigation-button-icon-prev"></span>
            <span class="sr-only!">Previous slide</span>
          </button>
          <button class="gallery-navigation-button-next" aria-controls="gallery-1-swiper">
            <span class="sr-only!">Next slide</span>
            <span class="gallery-navigation-button-icon gallery-navigation-button-icon-next"></span>
          </button>
        </div>
      </div>
    </div>
  </div>
</section>

Empty icon spans draw the default arrows; change them with blocks.gallery.prevIcon and blocks.gallery.nextIcon (see Component CSS blocks).

ClassStyles
gallery& { overflow: clip; width: 100%; display: flex; flex-direction: column; row-gap: var(--gallery-space-y, 48px); }& .gallery-wrapper { user-select: none; display: grid; row-gap: var(--gallery-wrapper-gap-y, 16px); }.gallery .gallery-wrapper { .swiper-slide { > * { width: auto; height: var(--gallery-slide-height, 300px); } } }+ 12 more rules
gallery-navigation-pagination.posts-list-pagination .gallery-navigation-pagination { font-family: var(--font-gallery-navigation-pagination-font-family); font-style: var(--font-gallery-navigation-pagination-font-style); font-weight: var(--font-gallery-navigation-pagination-font-weight); font-size: var(--gallery-navigation-pagination-font-size, 14px); line-height: var(--gallery-navigation-pagination-line-height, 100%); letter-spacing: var(--gallery-navigation-pagination-letter-spacing, 0px); color: var(--…
gallery-navigation-button-prev.posts-list-pagination .gallery-navigation-button-prev { --tw-shadow: var(--tw-shadow-color); --gallery-navigation-button-shadow-x: var(--gallery-navigation-button-shadow-x, 0px); --gallery-navigation-button-shadow-y: var(--gallery-navigation-button-shadow-y, 0px); --gallery-navigation-button-shadow-blur: var(--gallery-navigation-button-shadow-blur, 0px); --gallery-navigation-button-shadow-spread: var(--gallery-navigation-button-shadow-spread, 0px); --tw-shadow-color: var(--…+ 2 more rules
gallery-navigation-button-next.posts-list-pagination .gallery-navigation-button-next { --tw-shadow: var(--tw-shadow-color); --gallery-navigation-button-shadow-x: var(--gallery-navigation-button-shadow-x, 0px); --gallery-navigation-button-shadow-y: var(--gallery-navigation-button-shadow-y, 0px); --gallery-navigation-button-shadow-blur: var(--gallery-navigation-button-shadow-blur, 0px); --gallery-navigation-button-shadow-spread: var(--gallery-navigation-button-shadow-spread, 0px); --tw-shadow-color: var(--…+ 2 more rules
gallery-navigation-button-icon.posts-list-pagination .gallery-navigation-button-icon { display: inline-block; overflow: visible; object-fit: contain; object-position: center; flex-shrink: 0; flex-grow: 0; transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, box-shadow, opacity; width: var(--gallery-navigation-button-icon-size, 16px); height: var(--gallery-navigation-button-icon-size, 16px); color: var(--color-gallery-navigation-button-normal-icon, inherit); }+ 1 more rule
gallery-navigation-button-icon-prev.posts-list-pagination span.gallery-navigation-button-icon-prev:empty { display: inline-block; mask-image: url("data:image/svg+xml,…"); mask-size: var(--gallery-navigation-button-icon-size, 16px) var(--gallery-navigation-button-icon-size, 16px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-gallery-navigation-button-normal-icon, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 1 more rule
gallery-navigation-button-icon-next.posts-list-pagination span.gallery-navigation-button-icon-next:empty { display: inline-block; mask-image: url("data:image/svg+xml,…"); mask-size: var(--gallery-navigation-button-icon-size, 16px) var(--gallery-navigation-button-icon-size, 16px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-gallery-navigation-button-normal-icon, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 1 more rule

Next.jsLink to this section

@systhemaui/next re-exports the gallery components from @systhemaui/react unchanged. Use the Next Image inside GalleryItem for optimized slides, and pass a sizes value that matches the slide width.

AccessibilityLink to this section

  • The {current} / {total} counter is a role="status" region and the gallery's only slide announcement; Swiper's own live messages are switched off so nothing is announced twice. It counts images: total is the number of GalleryItems and current is the left-most image on screen. Because several images are visible at once, the counter can stop short of N / N when the last images all fit; the disabled next button marks the end.
  • The navigation buttons are icon-only, each with one visually hidden label. A disabled button is dimmed and shows a not-allowed cursor.
  • Give every slide image a meaningful alt, or alt="" when the images are decorative.

See Lists and counters.