Docs
Systhema Design (opens in new tab)
Unreleased

Carousel

Carousels with pagination, navigation and thumbs.

On this page

Carousel shows one slide at a time, with optional arrows and pagination over the slides or in a bar below them, and an optional strip of thumbnails. It is a Figure, so it takes the same width breakouts. The slider is Swiper (opens in new tab), loaded only on pages that have a carousel.

import { Carousel, CarouselItem, Image } from '@systhemaui/next'

const slides = [
  { src: '/demo-assets/coast.webp', alt: 'A rocky coastline at dusk' },
  { src: '/demo-assets/workspace.webp', alt: 'A tidy desk with a laptop' },
  { src: '/demo-assets/abstract-square.webp', alt: 'Soft abstract shapes' },
]

export default function Demo() {
  return (
    <div className="w-full max-w-3xl">
      <Carousel loop pagination="wrapper" navigation="wrapper">
        {slides.map((slide) => (
          <CarouselItem key={slide.src}>
            <Image src={slide.src} alt={slide.alt} width={1800} height={1200} aspectRatio="16/9" />
          </CarouselItem>
        ))}
      </Carousel>
    </div>
  )
}

ImportLink to this section

import { Carousel, CarouselItem } from '@systhemaui/next'

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

Variants and tagsLink to this section

Carousel renders a <figure class="carousel">, and each CarouselItem becomes one full-width slide. Three props shape the controls:

Prop'none' (default)'wrapper''bottom'
navigationno arrowsarrows over the slide, at its left and rightarrows in a bar under the slide
paginationno paginationclickable bullets over the bottom of the slidea 1 / 3 fraction in the bar under the slide

thumbs adds a strip of clickable thumbnails under the slide, made from the same children. loop wraps from the last slide to the first; it only applies with two or more slides.

ExamplesLink to this section

Controls below the slideLink to this section

With 'bottom', the fraction and the arrows sit in a bar under the slide instead of on top of it, which keeps the image unobstructed.

import { Carousel, CarouselItem, Image } from '@systhemaui/next'

const slides = ['/demo-assets/coast.webp', '/demo-assets/workspace.webp']

export default function Demo() {
  return (
    <div className="w-full max-w-3xl">
      <Carousel pagination="bottom" navigation="bottom">
        {slides.map((src) => (
          <CarouselItem key={src}>
            <Image src={src} alt="" width={1800} height={1200} aspectRatio="16/9" />
          </CarouselItem>
        ))}
      </Carousel>
    </div>
  )
}

ThumbnailsLink to this section

thumbs renders the slides again as square thumbnails under the main slider. The active one is fully opaque, the others dimmed; click one to show it.

import { Carousel, CarouselItem, Image } from '@systhemaui/next'

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

export default function Demo() {
  return (
    <div className="w-full max-w-3xl">
      <Carousel navigation="wrapper" thumbs>
        {slides.map((slide) => (
          <CarouselItem key={slide.src}>
            <Image
              src={slide.src}
              alt=""
              width={slide.width}
              height={slide.height}
              aspectRatio="16/9"
            />
          </CarouselItem>
        ))}
      </Carousel>
    </div>
  )
}

Full-bleed widthLink to this section

width works as on Figure: 'default' fills the column, 'container' breaks out to the container width, and 'screen' to the full viewport, without radius or shadow. With 'screen', the thumbnails and the bottom bar are not rendered, so use 'wrapper' controls.

import { Carousel, CarouselItem, Image } from '@systhemaui/next'

export default function Demo() {
  return (
    <Carousel width="screen" loop pagination="wrapper" navigation="wrapper">
      <CarouselItem>
        <Image src="/demo-assets/mountains.webp" alt="" width={1920} height={1080} aspectRatio="21/9" />
      </CarouselItem>
      <CarouselItem>
        <Image src="/demo-assets/city.webp" alt="" width={1920} height={1080} aspectRatio="21/9" />
      </CarouselItem>
    </Carousel>
  )
}

Translated button labelsLink to this section

The arrows are labelled "Previous slide" and "Next slide". Pass labels to translate them.

import { Carousel, CarouselItem, Image } from '@systhemaui/next'

export function Diashow({ photos }: { photos: { src: string; alt: string }[] }) {
  return (
    <Carousel
      navigation="wrapper"
      labels={{ previousSlide: 'Vorheriges Bild', nextSlide: 'Nächstes Bild' }}
    >
      {photos.map((photo) => (
        <CarouselItem key={photo.src}>
          <Image src={photo.src} alt={photo.alt} width={1800} height={1200} aspectRatio="16/9" />
        </CarouselItem>
      ))}
    </Carousel>
  )
}

LoadingLink to this section

Carousel is a client component. Both Swiper instances (main slider and thumbnails) come from one lazily imported module in its own chunk, about 120 KB that pages without a carousel never fetch. Until it arrives, the slides render with the same .swiper markup, and the server render already contains the real markup. See Swiper is loaded on demand.

PropsLink to this section

width defaults to 'default', loop and thumbs to false, pagination and navigation to 'none'. disableAnimation drops the configured animationClasses.

PropTypeDefaultDescription
childrenReactNode-
width'default' | 'container' | 'screen' | 'card' | null'default'
classNamestring-
loopbooleanfalse
pagination'none' | 'wrapper' | 'bottom''none'
navigation'none' | 'wrapper' | 'bottom''none'
thumbsbooleanfalse
disableAnimationbooleanfalse
labels{ previousSlide?, nextSlide? }-Override the prev/next navigation button aria-labels (for localization).
…and all inherited HTML attributes

CarouselItemLink to this section

CarouselItem renders its child as a slide and takes no other props.

PropTypeDefaultDescription
children (required)ReactNode-

HTML and CSSLink to this section

The carousel classes style the Swiper markup; the slider itself needs Swiper. A static version with arrows over the slide:

<figure class="figure-w-default carousel" data-carousel-id="c1">
  <div class="carousel-wrapper">
    <div class="swiper" id="c1-swiper">
      <div class="swiper-wrapper">
        <div class="swiper-slide">
          <img class="media aspect-16/9 object-cover" src="/coast.webp" alt="" />
        </div>
      </div>
    </div>
    <div class="carousel-wrapper-pagination"></div>
    <button class="carousel-wrapper-button-prev" aria-controls="c1-swiper">
      <span class="sr-only!">Previous slide</span>
      <span class="carousel-wrapper-button-icon carousel-wrapper-button-icon-prev"></span>
    </button>
    <button class="carousel-wrapper-button-next" aria-controls="c1-swiper">
      <span class="sr-only!">Next slide</span>
      <span class="carousel-wrapper-button-icon carousel-wrapper-button-icon-next"></span>
    </button>
  </div>
</figure>

Empty icon spans draw the default arrows. Button size, radius, colors, the pagination gap and the thumbnail size all come from the carousel tokens.

ClassStyles
carouseldisplay: flex;flex-direction: column;row-gap: var(--carousel-gap-y, 16px);
carousel-wrapper& { position: relative; overflow: hidden; }& .swiper-slide { width: 100%; height: auto; }.carousel-wrapper .swiper-slide { > * { &:is(img, video, iframe), img, video, iframe { border-radius: 0; } } }figure:not(.figure-w-full):not(.figure-w-screen) .carousel-wrapper { border-radius: var(--carousel-wrapper-border-radius, 0px); border-width: var(--carousel-wrapper-border-width, 0px); border-color: var(--color-carousel-wrapper-border, transparent); }+ 1 more rule
carousel-wrapper-pagination& { z-index: 10; position: absolute; left: 50%; translate: -50% 0; bottom: 24px; display: inline-flex; justify-content: center; align-items: center; column-gap: var(--carousel-wrapper-pagination-gap-x, 8px); }+ 3 more rules
carousel-wrapper-button-prev& { --tw-shadow-color: var(--color-carousel-wrapper-button-normal-shadow, transparent); cursor: pointer; z-index: 10; position: absolute; top: 50%; translate: 0 -50%; display: inline-grid; place-items: center; width: var(--carousel-wrapper-button-size, 24px); height: var(--carousel-wrapper-button-size, 24px); border-radius: var(--carousel-wrapper-button-border-radius, 9999px); border-width: var(--carousel-wrapper-button-border-width, 0px); box-shadow: var(--carousel-wrapper-…+ 3 more rules
carousel-wrapper-button-next& { --tw-shadow-color: var(--color-carousel-wrapper-button-normal-shadow, transparent); cursor: pointer; z-index: 10; position: absolute; top: 50%; translate: 0 -50%; display: inline-grid; place-items: center; width: var(--carousel-wrapper-button-size, 24px); height: var(--carousel-wrapper-button-size, 24px); border-radius: var(--carousel-wrapper-button-border-radius, 9999px); border-width: var(--carousel-wrapper-button-border-width, 0px); box-shadow: var(--carousel-wrapper-…+ 3 more rules
carousel-wrapper-button-icon-prevdiv.carousel-wrapper-button-icon-prev:empty { display: inline-block; mask-image: url("data:image/svg+xml,…"); mask-size: var(--carousel-wrapper-button-icon-size, 24px) var(--carousel-wrapper-button-icon-size, 24px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-carousel-wrapper-button-normal-fill, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 3 more rules
carousel-wrapper-button-icon-nextdiv.carousel-wrapper-button-icon-next:empty { display: inline-block; mask-image: url("data:image/svg+xml,…"); mask-size: var(--carousel-wrapper-button-icon-size, 24px) var(--carousel-wrapper-button-icon-size, 24px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-carousel-wrapper-button-normal-fill, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 3 more rules
carousel-thumbs& { overflow: hidden; }& .swiper-slide { cursor: pointer; overflow: hidden; aspect-ratio: 1/1; transition-property: opacity; width: var(--carousel-thumbs-item-size, 64px); height: var(--carousel-thumbs-item-size, 64px); border-radius: var(--carousel-thumbs-border-radius, 0px); border-width: var(--carousel-thumbs-border-width, 0px); border-color: var(--color-carousel-thumbs-border, currentColor); opacity: calc(var(--color-carousel-thumbs-default-opacity, 50) / 100); }+ 2 more rules
carousel-bottomdisplay: flex;justify-content: space-between;align-items: center;padding-top: var(--carousel-bottom-padding-top, 0px);gap: var(--carousel-bottom-gap-x, 10px);
carousel-bottom-paginationfont-family: var(--font-carousel-bottom-pagination-font-family);font-style: var(--font-carousel-bottom-pagination-font-style);font-weight: var(--font-carousel-bottom-pagination-font-weight);font-size: var(--carousel-bottom-pagination-font-size, 14px);line-height: var(--carousel-bottom-pagination-line-height, 100%);letter-spacing: var(--carousel-bottom-pagination-letter-spacing, 0px);color: var(--color-carousel-bottom-pagination-text, inherit);
carousel-bottom-buttons& { display: flex; align-items: center; justify-content: center; flex-direction: row; gap: var(--carousel-bottom-gap-x, 10px); }& .swiper-button-disabled { opacity: 0.5; cursor: not-allowed; }
carousel-bottom-button-prev& { --tw-shadow: var(--tw-shadow-color); --carousel-bottom-button-shadow-x: var(--carousel-bottom-button-shadow-x, 0px); --carousel-bottom-button-shadow-y: var(--carousel-bottom-button-shadow-y, 0px); --carousel-bottom-button-shadow-blur: var(--carousel-bottom-button-shadow-blur, 0px); --carousel-bottom-button-shadow-spread: var(--carousel-bottom-button-shadow-spread, 0px); --tw-shadow-color: var(--color-carousel-bottom-button-normal-shadow, transparent); vertical-align: bot…+ 1 more rule
carousel-bottom-button-next& { --tw-shadow: var(--tw-shadow-color); --carousel-bottom-button-shadow-x: var(--carousel-bottom-button-shadow-x, 0px); --carousel-bottom-button-shadow-y: var(--carousel-bottom-button-shadow-y, 0px); --carousel-bottom-button-shadow-blur: var(--carousel-bottom-button-shadow-blur, 0px); --carousel-bottom-button-shadow-spread: var(--carousel-bottom-button-shadow-spread, 0px); --tw-shadow-color: var(--color-carousel-bottom-button-normal-shadow, transparent); vertical-align: bot…+ 1 more rule
carousel-bottom-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(--carousel-bottom-button-icon-size, 16px); height: var(--carousel-bottom-button-icon-size, 16px); color: var(--color-carousel-bottom-button-normal-icon, inherit); }+ 3 more rules
carousel-bottom-button-icon-prevdiv.carousel-bottom-button-icon-prev:empty { mask-image: url("data:image/svg+xml,…"); mask-size: var(--carousel-bottom-button-icon-size, 16px) var(--carousel-bottom-button-icon-size, 16px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-carousel-bottom-button-normal-icon, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 3 more rules
carousel-bottom-button-icon-nextdiv.carousel-bottom-button-icon-next:empty { mask-image: url("data:image/svg+xml,…"); mask-size: var(--carousel-bottom-button-icon-size, 16px) var(--carousel-bottom-button-icon-size, 16px); mask-repeat: no-repeat; mask-position: center; background-color: var(--color-carousel-bottom-button-normal-icon, inherit); transition-property: background-color; transition-duration: inherit; transition-timing-function: inherit; }+ 3 more rules

Next.jsLink to this section

@systhemaui/next re-exports Carousel and CarouselItem from @systhemaui/react unchanged. Use the Next Image in the slides for optimized files.

AccessibilityLink to this section

  • The carousel answers the keyboard (arrow keys while it is in the viewport), horizontal mouse-wheel and trackpad gestures, dragging and touch.
  • Swiper's A11y module adds slide roles and labels and announces slide changes.
  • The arrows are icon-only buttons, each with one visually hidden label. Translate them with labels.
  • Give each slide image a meaningful alt, or alt="" when the slides are decorative.