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-carouselImport 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 & 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.
| Key | Action |
|---|---|
| ← | Previous slide |
| → | Next slide |
| Home | First slide |
| End | Last slide |
Customization
Override the --carousel-* vars in a parent scope to retune the surface, controls, and indicator chrome.
Geometry
| Variable | Default | Use |
|---|---|---|
--carousel-radius | var(--st-radius-lg) | Viewport corner radius. |
--carousel-aspect-ratio | 16 / 9 | Viewport aspect ratio. Opt out via the --no-aspect modifier on the root. |
--carousel-slide-gap | 0 | Gap between adjacent slides. Applied as start-side padding on each slide. |
Controls
| Variable | Default | Use |
|---|---|---|
--carousel-control-size | 2.25rem | Prev / next chip diameter. |
--carousel-control-inset | 0.75rem | Distance from the chip to the slide edge. |
--carousel-control-bg | translucent dark | Chip rest background. |
--carousel-control-bg-hover | opaque dark | Chip hover background. |
--carousel-control-color | near-white | Icon color. |
--carousel-control-shadow | soft drop | Chip shadow. |
Indicators
| Variable | Default | Use |
|---|---|---|
--carousel-indicators-inset | 0.875rem | Distance from the indicator row to the slide bottom. |
--carousel-indicator-size | 0.375rem | Dot diameter at rest. |
--carousel-indicator-gap | 0.375rem | Space between dots. |
--carousel-indicator-bg | translucent light | Inactive dot fill. |
--carousel-indicator-active-bg | opaque light | Active pill fill. |
--carousel-indicator-active-width | 1.25rem | Active pill width. Width transitions between dot and pill. |
Caption
| Variable | Default | Use |
|---|---|---|
--carousel-caption-padding-y | 1.25rem | Caption vertical padding. |
--carousel-caption-padding-x | 1.25rem | Caption horizontal padding. |
--carousel-caption-color | near-white | Caption text color. |
--carousel-caption-bg | gradient overlay | Background. Defaults to a transparent-to-dark gradient so text reads over the image. |
Motion
| Variable | Default | Use |
|---|---|---|
--carousel-transition-duration | 0.2s | Indicator width and control color transitions. Reduced motion zeros both. |