Carousel

A slideshow that cycles through any content.

Installation

Carousel is an integration component. It's already in the kitchen-sink stisla-full bundle; install it alongside the core stisla bundle either as script tags or via your package manager.

Add the integration's stylesheet and script next to the ones loading the core bundle.

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@stisla/vanilla@3/dist/integrations/carousel.css">
<script type="module" src="https://cdn.jsdelivr.net/npm/@stisla/vanilla@3/dist/integrations/carousel.js"></script>

The integration script auto-registers on the global Stisla instance, so no extra wiring is needed.

Install Embla:

npm install embla-carousel

Import the integration's CSS + class, register it, then call Stisla.init():

import '@stisla/vanilla/dist/integrations/carousel.css';
import { Stisla } from '@stisla/vanilla';
import { Carousel } from '@stisla/vanilla/integrations/carousel';

Stisla.register('carousel', Carousel);
Stisla.init();

Basic

Three parts. A .carousel root, a .carousel__viewport with a .carousel__track inside, and one .carousel__slide per item. Drag to scrub.

<div class="carousel" data-stisla-carousel role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="Mountains above the clouds" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="City skyline at sunset" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="Eiffel Tower at dusk" />
      </div>
    </div>
  </div>
</div>

With controls

Add .carousel__control--prev and .carousel__control--next as direct children of the root. The wrapper auto-disables the chip at the end of the track unless loop is on.

<div class="carousel" data-stisla-carousel role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="Mountains above the clouds" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="City skyline at sunset" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="Eiffel Tower at dusk" />
      </div>
    </div>
  </div>
  <button type="button" class="carousel__control carousel__control--prev" aria-label="Previous slide">
    <i data-lucide="chevron-left"></i>
  </button>
  <button type="button" class="carousel__control carousel__control--next" aria-label="Next slide">
    <i data-lucide="chevron-right"></i>
  </button>
</div>

With indicators

One .carousel__indicator button per slide inside .carousel__indicators. The wrapper paints the active chip via [data-state="active"] and aria-current="true".

<div class="carousel" data-stisla-carousel role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="Mountains above the clouds" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="City skyline at sunset" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="Eiffel Tower at dusk" />
      </div>
    </div>
  </div>
  <button type="button" class="carousel__control carousel__control--prev" aria-label="Previous slide">
    <i data-lucide="chevron-left"></i>
  </button>
  <button type="button" class="carousel__control carousel__control--next" aria-label="Next slide">
    <i data-lucide="chevron-right"></i>
  </button>
  <div class="carousel__indicators" role="tablist" aria-label="Slides">
    <button type="button" class="carousel__indicator" data-state="active" aria-current="true" aria-label="Go to slide 1"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 2"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 3"></button>
  </div>
</div>

With captions

Drop a .carousel__caption inside any .carousel__slide. It pins to the bottom edge with a gradient overlay.

<div class="carousel" data-stisla-carousel role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="" />
        <div class="carousel__caption">
          <h3 class="m-0 mb-1 fs-4 fw-semibold">Above the clouds</h3>
          <p class="m-0 fs-2">A break in the weather over the eastern alps.</p>
        </div>
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="" />
        <div class="carousel__caption">
          <h3 class="m-0 mb-1 fs-4 fw-semibold">City after dark</h3>
          <p class="m-0 fs-2">Lights up just as the last of the day fades out.</p>
        </div>
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="" />
        <div class="carousel__caption">
          <h3 class="m-0 mb-1 fs-4 fw-semibold">Iron lattice</h3>
          <p class="m-0 fs-2">Paris in the gold hour from the Trocadero.</p>
        </div>
      </div>
    </div>
  </div>
  <button type="button" class="carousel__control carousel__control--prev" aria-label="Previous slide">
    <i data-lucide="chevron-left"></i>
  </button>
  <button type="button" class="carousel__control carousel__control--next" aria-label="Next slide">
    <i data-lucide="chevron-right"></i>
  </button>
  <div class="carousel__indicators" role="tablist" aria-label="Slides">
    <button type="button" class="carousel__indicator" data-state="active" aria-current="true" aria-label="Go to slide 1"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 2"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 3"></button>
  </div>
</div>

Card content

Slides aren't limited to images. Add .carousel--no-aspect on the root and the viewport sizes to its tallest slide instead of locking to 16 / 9. The chrome tokens can be retuned to track theme surfaces so the controls and indicators read on both light and dark.

<div class="carousel carousel--no-aspect" data-stisla-carousel data-stisla-carousel-loop="true" role="region" aria-roledescription="carousel" aria-label="Customer stories"
     style="--carousel-control-bg: var(--st-surface); --carousel-control-bg-hover: var(--st-accent); --carousel-control-color: var(--st-foreground); --carousel-indicator-bg: var(--st-border); --carousel-indicator-active-bg: var(--st-primary); --carousel-indicators-inset: -1rem;">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <div class="card m-0">
          <div class="card__body p-8">
            <p class="m-0" style="font-size: 1.0625rem; line-height: 1.6;">"Stisla took the headache out of rebuilding our internal admin tool. The token system meant we could ship a brand refresh in a single PR, with no per-component overrides."</p>
            <div class="mt-5">
              <strong>Maya Tanaka</strong>
              <div class="text-muted-foreground fs-2">Engineering Lead, Northwind Labs</div>
            </div>
          </div>
        </div>
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <div class="card m-0">
          <div class="card__body p-8">
            <p class="m-0" style="font-size: 1.0625rem; line-height: 1.6;">"We bought into the BS5 version for a client project two years ago. The v3 rewrite kept everything we liked and dropped the bits we were already hacking around."</p>
            <div class="mt-5">
              <strong>Diego Romero</strong>
              <div class="text-muted-foreground fs-2">Design Engineer, Forge &amp; Tide</div>
            </div>
          </div>
        </div>
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <div class="card m-0">
          <div class="card__body p-8">
            <p class="m-0" style="font-size: 1.0625rem; line-height: 1.6;">"Dark mode used to be a quarterly bug ticket. Now it's a single <code>[data-theme]</code> flip on the root element and we don't think about it again."</p>
            <div class="mt-5">
              <strong>Priya Reddy</strong>
              <div class="text-muted-foreground fs-2">Product Engineer, Helix Health</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
  <button type="button" class="carousel__control carousel__control--prev" aria-label="Previous testimonial">
    <i data-lucide="chevron-left"></i>
  </button>
  <button type="button" class="carousel__control carousel__control--next" aria-label="Next testimonial">
    <i data-lucide="chevron-right"></i>
  </button>
  <div class="carousel__indicators" role="tablist" aria-label="Testimonials">
    <button type="button" class="carousel__indicator" data-state="active" aria-current="true" aria-label="Testimonial 1"></button>
    <button type="button" class="carousel__indicator" aria-label="Testimonial 2"></button>
    <button type="button" class="carousel__indicator" aria-label="Testimonial 3"></button>
  </div>
</div>

Autoplay

Pass data-stisla-carousel-autoplay="true" for a 4 s tick, or { "delay": 6000 } for a custom delay. Autoplay pauses on hover, on focus, while dragging, and while the tab is hidden. Reduced motion turns it off entirely.

<div class="carousel" data-stisla-carousel data-stisla-carousel-autoplay="true" data-stisla-carousel-loop="true" role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="Mountains above the clouds" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="City skyline at sunset" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="Eiffel Tower at dusk" />
      </div>
    </div>
  </div>
  <div class="carousel__indicators" role="tablist" aria-label="Slides">
    <button type="button" class="carousel__indicator" data-state="active" aria-current="true" aria-label="Go to slide 1"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 2"></button>
    <button type="button" class="carousel__indicator" aria-label="Go to slide 3"></button>
  </div>
</div>

Loop

data-stisla-carousel-loop="true" wraps past the last slide back to the first. The prev / next chips stay enabled at the ends.

<div class="carousel" data-stisla-carousel data-stisla-carousel-loop="true" role="region" aria-roledescription="carousel" aria-label="Travel destinations">
  <div class="carousel__viewport">
    <div class="carousel__track">
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="1 of 3">
        <img src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1600&h=900&q=70" alt="Mountains above the clouds" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="2 of 3">
        <img src="https://images.unsplash.com/photo-1480714378408-67cf0d13bc1b?auto=format&fit=crop&w=1600&h=900&q=70" alt="City skyline at sunset" />
      </div>
      <div class="carousel__slide" role="group" aria-roledescription="slide" aria-label="3 of 3">
        <img src="https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1600&h=900&q=70" alt="Eiffel Tower at dusk" />
      </div>
    </div>
  </div>
  <button type="button" class="carousel__control carousel__control--prev" aria-label="Previous slide">
    <i data-lucide="chevron-left"></i>
  </button>
  <button type="button" class="carousel__control carousel__control--next" aria-label="Next slide">
    <i data-lucide="chevron-right"></i>
  </button>
</div>

Keyboard

The root is focusable. Once focus lands on the carousel itself (not on a slide child), these keys move the track.

KeyAction
Previous slide
Next slide
HomeFirst slide
EndLast slide

Customization

Override the --carousel-* vars in a parent scope to retune the surface, controls, and indicator chrome.

Geometry

VariableDefaultUse
--carousel-radiusvar(--st-radius-lg)Viewport corner radius.
--carousel-aspect-ratio16 / 9Viewport aspect ratio. Opt out via the --no-aspect modifier on the root.
--carousel-slide-gap0Gap between adjacent slides. Applied as start-side padding on each slide.

Controls

VariableDefaultUse
--carousel-control-size2.25remPrev / next chip diameter.
--carousel-control-inset0.75remDistance from the chip to the slide edge.
--carousel-control-bgtranslucent darkChip rest background.
--carousel-control-bg-hoveropaque darkChip hover background.
--carousel-control-colornear-whiteIcon color.
--carousel-control-shadowsoft dropChip shadow.

Indicators

VariableDefaultUse
--carousel-indicators-inset0.875remDistance from the indicator row to the slide bottom.
--carousel-indicator-size0.375remDot diameter at rest.
--carousel-indicator-gap0.375remSpace between dots.
--carousel-indicator-bgtranslucent lightInactive dot fill.
--carousel-indicator-active-bgopaque lightActive pill fill.
--carousel-indicator-active-width1.25remActive pill width. Width transitions between dot and pill.

Caption

VariableDefaultUse
--carousel-caption-padding-y1.25remCaption vertical padding.
--carousel-caption-padding-x1.25remCaption horizontal padding.
--carousel-caption-colornear-whiteCaption text color.
--carousel-caption-bggradient overlayBackground. Defaults to a transparent-to-dark gradient so text reads over the image.

Motion

VariableDefaultUse
--carousel-transition-duration0.2sIndicator width and control color transitions. Reduced motion zeros both.