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
| Part | Renders |
|---|---|
Gallery | a <section class="py-section gallery"> with optional theme and layoutBackground |
GalleryContent | the intro: a grid container with a richtext column, inset by padding grid columns (0 to 6) |
GalleryWrapper | the slider on the same grid, inset by padding, with the counter and the navigation buttons under it |
GalleryItem | one 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
GalleryLink to this section
Gallery takes every <section> attribute.
| Prop | Type | Default | Description |
|---|---|---|---|
theme | ColorSystem (dark, default) | - | |
layoutBackground | LayoutBackground (main, alternative) | - | |
…and all <section> attributes |
GalleryContentLink to this section
GalleryContent takes every <div> attribute; padding defaults to 0.
| Prop | Type | Default | Description |
|---|---|---|---|
padding | number | 0 | |
…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).
| Class | Styles |
|---|---|
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 arole="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:totalis the number ofGalleryItems andcurrentis the left-most image on screen. Because several images are visible at once, the counter can stop short ofN / Nwhen 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, oralt=""when the images are decorative.
See Lists and counters.