Carousel
A slideshow / carousel component built on Embla Carousel. Supports arrows, dots, looping, autoplay (with pause-on-hover/focus/interaction/last-snap), fade transitions, multiple slides per view, drag-free scrolling, horizontal & vertical orientations, responsive breakpoints, custom snippets, controlled mode, and a full Embla API.
New in this release. Highlights:
- Arrows, dots, looping, autoplay (with pause-on-hover/focus/interaction/last-snap)
- Fade transition, multiple slides per view (
slidesToShow/slidesToScroll), drag-free scrolling - Horizontal & vertical orientations, responsive
breakpoints - Custom
slide,dot,prevSlot,nextSlotsnippets - Full
bind:index+bind:apicontrolled mode onIndexChange/onSettlecallbacks- Size / color / variant tokens, per-slot
uioverrides
Playground
Toggle props in real-time. Drag, tap arrows, or click a dot to navigate.
Basic Usage
Pass an items array and render each via the slide snippet. Arrows and dots show by default.
<script lang="ts">
import { Carousel } from 'sv5ui';
const slides = Array.from({ length: 5 }, (_, i) => ({
id: i + 1,
src: `https://picsum.photos/seed/sv5ui-${i + 1}/800/450`,
alt: `Slide ${i + 1}`
}));
</script>
<Carousel items={slides}>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Loop
Enable loop so navigating past the last slide returns to the first.
<!-- Wrap around to the first slide after the last -->
<Carousel items={slides} loop>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Autoplay
Pass autoplay to advance slides automatically. Combine with loop for an infinite carousel.
<!-- Pass `true` for the defaults -->
<Carousel items={slides} loop autoplay>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Autoplay Options
Pass an object to fine-tune timing and pause behaviour. The example below uses a 2.5 s delay, pauses on hover/focus, and resumes after user interaction.
<!-- Customize delay & pause behaviour -->
<Carousel
items={slides}
loop
autoplay={{
delay: 2500,
stopOnInteraction: false, // resume after user interaction
stopOnMouseEnter: true, // pause on hover
stopOnFocusIn: true, // pause when keyboard focus enters
stopOnLastSnap: false // keep looping past the last slide
}}
>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Autoplay Options
When `autoplay` is an object, these keys are accepted.
| Option | Type | Default |
|---|---|---|
delay | number | 4000 |
stopOnInteraction | boolean | false |
stopOnMouseEnter | boolean | true |
stopOnFocusIn | boolean | true |
stopOnLastSnap | boolean | false |
playOnInit | boolean | true |
Fade Transition
Cross-fade between slides instead of sliding. Pairs well with image galleries.
<!-- Cross-fade between slides instead of sliding -->
<Carousel items={slides} loop fade autoplay>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Multiple Slides per View
Set slidesToShow to display several slides at once, and slidesToScroll to control how many advance per click.
<!-- Show 3 slides at a time, scrolling 1 at a time -->
<Carousel items={slides} slidesToShow={3} slidesToScroll={1} loop>
{#snippet slide({ item })}
<div class="px-1">
<img src={item.src} alt={item.alt} class="aspect-square w-full rounded-lg object-cover" />
</div>
{/snippet}
</Carousel>
<!-- Use 'auto' to let Embla decide based on viewport -->
<Carousel items={slides} slidesToShow={3} slidesToScroll="auto" loop />Drag-Free Scrolling
With dragFree, slides don't snap into place — useful for grid-like browse experiences.
<!-- Free-form scrolling without snapping -->
<Carousel
items={slides}
slidesToShow={3}
dragFree
arrows={false}
dots={false}
>
{#snippet slide({ item })}
<div class="px-1">
<img src={item.src} alt={item.alt} class="aspect-square w-full rounded-lg object-cover" />
</div>
{/snippet}
</Carousel>Vertical Orientation
Switch the axis with orientation="vertical". The container needs an explicit height for vertical scrolling to work.
<Carousel
items={slides}
orientation="vertical"
loop
class="h-80"
>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="h-full w-full rounded-lg object-cover" />
{/snippet}
</Carousel>Responsive Breakpoints
Use Tailwind responsive classes on ui.slide for visual layout, and the breakpoints prop for Embla-level options (like slidesToScroll). Resize the window to see this carousel adapt from 1 → 2 → 3 slides.
<!-- Show 1 slide on mobile, 2 on tablet, 3 on desktop -->
<Carousel
items={slides}
slidesToShow={1}
loop
breakpoints={{
'(min-width: 640px)': { slidesToScroll: 2 },
'(min-width: 1024px)': { slidesToScroll: 3 }
}}
ui={{
slide: 'basis-full sm:basis-1/2 lg:basis-1/3'
}}
>
{#snippet slide({ item })}
<div class="px-1">
<img src={item.src} alt={item.alt} class="aspect-square w-full rounded-lg object-cover" />
</div>
{/snippet}
</Carousel>Custom Slides
The slide snippet receives { item, index, selected } — use selected to emphasize the active slide.
<script lang="ts">
import { Carousel, Card, Button } from 'sv5ui';
const features = [
{ title: 'Fast', icon: 'lucide:zap', desc: 'Lightweight, GPU-accelerated.' },
{ title: 'Flexible', icon: 'lucide:settings', desc: 'Configurable to any layout.' },
{ title: 'Accessible', icon: 'lucide:accessibility', desc: 'Full keyboard + ARIA support.' }
];
</script>
<Carousel items={features} loop autoplay>
{#snippet slide({ item, selected })}
<Card class="mx-2 transition-transform duration-300 {selected ? 'scale-100' : 'scale-95 opacity-70'}">
{#snippet header()}
<h3 class="text-lg font-semibold">{item.title}</h3>
{/snippet}
<p class="text-on-surface/70 text-sm">{item.desc}</p>
</Card>
{/snippet}
</Carousel>
<!-- The `selected` slot prop lets you visually emphasize the active slide. -->Custom Dots
Replace the default dots via the dot snippet — it receives { index, selected, select() }.
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
</script>
<!-- Replace the default dots with numbered Button pills -->
<Carousel items={slides} loop>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
{#snippet dot({ index, selected, select })}
<Button
variant={selected ? 'solid' : 'soft'}
color={selected ? 'primary' : 'surface'}
size="xs"
square
label={`${index + 1}`}
onclick={select}
aria-label={`Go to slide ${index + 1}`}
aria-current={selected || undefined}
class="size-7 rounded-full text-xs font-semibold"
/>
{/snippet}
</Carousel>Custom Arrows
Use the prevSlot / nextSlot snippets to fully replace the arrows. Each receives { canScroll, scroll() }.
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
</script>
<Carousel items={slides} loop>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
{#snippet prevSlot({ canScroll, scroll })}
<Button
variant="solid"
color="surface"
icon="lucide:arrow-left"
size="lg"
square
disabled={!canScroll}
onclick={scroll}
aria-label="Previous"
class="absolute left-3 top-1/2 -translate-y-1/2 shadow-lg backdrop-blur"
/>
{/snippet}
{#snippet nextSlot({ canScroll, scroll })}
<Button
variant="solid"
color="surface"
icon="lucide:arrow-right"
size="lg"
square
disabled={!canScroll}
onclick={scroll}
aria-label="Next"
class="absolute right-3 top-1/2 -translate-y-1/2 shadow-lg backdrop-blur"
/>
{/snippet}
</Carousel>Controlled (bind:index)
Use bind:index to drive the carousel from outside.
Slide 1 / 5
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
let index = $state(0);
</script>
<Carousel items={slides} bind:index loop arrows={false} dots={false}>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>
<!-- External controls -->
<div class="mt-4 flex items-center justify-between gap-3">
<Button variant="outline" leadingIcon="lucide:chevron-left" label="Prev"
onclick={() => (index = (index - 1 + slides.length) % slides.length)} />
<p class="text-on-surface/60 text-sm">
Slide <strong>{index + 1}</strong> / {slides.length}
</p>
<Button variant="outline" trailingIcon="lucide:chevron-right" label="Next"
onclick={() => (index = (index + 1) % slides.length)} />
</div>API & Callbacks
Bind the Embla api for imperative control — pause autoplay, scroll programmatically, or read state. The component also exposes onIndexChange and onSettle callbacks.
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
import type { CarouselApi } from 'sv5ui';
let api = $state<CarouselApi | undefined>();
let isPlaying = $state(true);
function togglePlayback() {
const ap = api?.plugins().autoplay as { play: () => void; stop: () => void; isPlaying: () => boolean } | undefined;
if (!ap) return;
if (ap.isPlaying()) ap.stop();
else ap.play();
isPlaying = ap.isPlaying();
}
</script>
<Carousel
items={slides}
bind:api
loop
autoplay
onIndexChange={(i) => console.log('Now on slide', i)}
onSettle={(api) => console.log('Settled at', api.selectedScrollSnap())}
>
{#snippet slide({ item })}
<img src={item.src} alt={item.alt} class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>
<Button
leadingIcon={isPlaying ? 'lucide:pause' : 'lucide:play'}
label={isPlaying ? 'Pause autoplay' : 'Resume autoplay'}
onclick={togglePlayback}
class="mt-3"
/>
<!-- The Embla `api` exposes `scrollPrev()`, `scrollNext()`, `scrollTo(i)`,
`selectedScrollSnap()`, `plugins()`, and many more. -->Gallery — Horizontal Thumbnails
A classic photo gallery: a large main image with a clickable thumbnail strip below. Two carousels share index state — clicking a thumbnail scrolls the main image, and the thumb strip auto-scrolls to keep the active thumbnail visible.
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
import type { CarouselApi } from 'sv5ui';
const gallery = Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
full: `https://picsum.photos/seed/sv5ui-gallery-${i + 1}/900/560`,
thumb: `https://picsum.photos/seed/sv5ui-gallery-${i + 1}/200/150`,
alt: `Photo ${i + 1}`
}));
let index = $state(0);
let thumbApi = $state<CarouselApi | undefined>();
// Keep the thumbnail strip aligned with the active image
$effect(() => { thumbApi?.scrollTo(index); });
</script>
<div class="space-y-3">
<!-- Main image: driven by `index` -->
<Carousel items={gallery} bind:index loop arrows={false} dots={false}>
{#snippet slide({ item })}
<img src={item.full} alt={item.alt}
class="aspect-video w-full rounded-lg object-cover" />
{/snippet}
</Carousel>
<!-- Thumbnail strip: drives `index` on click.
`ui.slide` adds `p-1.5` padding so the active ring isn't clipped
by the viewport's overflow-hidden. -->
<Carousel
items={gallery}
bind:api={thumbApi}
slidesToShow={6}
arrows={false}
dots={false}
ui={{ slide: 'basis-1/4 sm:basis-1/6 p-1.5' }}
>
{#snippet slide({ item, index: i })}
<Button
variant="ghost"
color="surface"
onclick={() => (index = i)}
aria-label={`Show ${item.alt}`}
aria-current={index === i || undefined}
class="h-auto w-full overflow-hidden rounded-md p-0 ring-2 transition
{index === i
? 'ring-primary opacity-100'
: 'ring-transparent opacity-60 hover:opacity-100'}"
>
<img src={item.thumb} alt={item.alt}
class="aspect-square w-full object-cover" />
</Button>
{/snippet}
</Carousel>
</div>Gallery — Vertical Thumbnails
Same pattern, with the thumbnail strip in a vertical column on the left of the main image. The thumbnail carousel uses orientation="vertical" with a fixed height matching the main image.
<script lang="ts">
import { Carousel, Button } from 'sv5ui';
import type { CarouselApi } from 'sv5ui';
const gallery = Array.from({ length: 8 }, (_, i) => ({
id: i + 1,
full: `https://picsum.photos/seed/sv5ui-gallery-${i + 1}/900/560`,
thumb: `https://picsum.photos/seed/sv5ui-gallery-${i + 1}/200/150`,
alt: `Photo ${i + 1}`
}));
let index = $state(0);
let thumbApi = $state<CarouselApi | undefined>();
$effect(() => { thumbApi?.scrollTo(index); });
</script>
<!--
Key trick: keep slides SQUARE so `aspect-square` thumbs fit cleanly.
Slide width = column width
Slide height = carousel height / slidesToShow
Match them: column 5rem → carousel h-80 (4 × 80px)
column 6rem → carousel h-96 (4 × 96px)
-->
<div class="grid grid-cols-[5rem_1fr] gap-3 sm:grid-cols-[6rem_1fr]">
<!-- Vertical thumbnail strip. `ui.slide` adds padding so each thumb
has room for the active ring and even vertical spacing. -->
<Carousel
items={gallery}
bind:api={thumbApi}
orientation="vertical"
slidesToShow={4}
arrows={false}
dots={false}
class="h-80 sm:h-96"
ui={{ slide: 'p-1.5' }}
>
{#snippet slide({ item, index: i })}
<Button
variant="ghost"
color="surface"
onclick={() => (index = i)}
aria-label={`Show ${item.alt}`}
aria-current={index === i || undefined}
class="h-auto w-full overflow-hidden rounded-md p-0 ring-2 transition
{index === i
? 'ring-primary opacity-100'
: 'ring-transparent opacity-60 hover:opacity-100'}"
>
<img src={item.thumb} alt={item.alt}
class="aspect-square w-full object-cover" />
</Button>
{/snippet}
</Carousel>
<!-- Main image -->
<Carousel items={gallery} bind:index loop dots={false} class="h-80 sm:h-96">
{#snippet slide({ item })}
<img src={item.full} alt={item.alt}
class="h-80 w-full rounded-lg object-cover sm:h-96" />
{/snippet}
</Carousel>
</div>UI Slots
Use the `ui` prop to override classes on internal elements.
| Slot | Description |
|---|---|
root | Outermost wrapper around viewport, arrows, and dots |
viewport | Clipping container (overflow-hidden) that Embla observes |
container | Flex track that holds the slides and is moved by Embla |
slide | Wrapper around each item — controls `flex-basis` for `slidesToShow` |
arrow | Shared classes applied to both arrow buttons |
arrowPrev | Previous arrow button position |
arrowNext | Next arrow button position |
dots | Pagination dots container |
dot | Each pagination dot button |
Snippets
| Snippet | Description |
|---|---|
slide | Render each item. Receives `{ item, index, selected }` |
dot | Custom dot. Receives `{ index, selected, select() }` |
prevSlot | Custom previous arrow. Receives `{ canScroll, scroll() }` |
nextSlot | Custom next arrow. Receives `{ canScroll, scroll() }` |
children | Fallback when `items` is not provided — pass slide elements directly |
Props
| Prop | Type | Default |
|---|---|---|
items | T[] | - |
index | number | 0 |
onIndexChange | (index: number) => void | - |
onSettle | (api: CarouselApi) => void | - |
api | CarouselApi | undefined | - |
slidesToShow | number | 1 |
slidesToScroll | number | 'auto' | 1 |
loop | boolean | false |
align | 'start' | 'center' | 'end' | 'start' |
orientation | 'horizontal' | 'vertical' | 'horizontal' |
draggable | boolean | true |
dragFree | boolean | false |
autoplay | boolean | CarouselAutoplayOptions | false |
fade | boolean | false |
classNames | boolean | false |
breakpoints | Record<string, EmblaOptionsType> | - |
plugins | EmblaPluginType[] | - |
options | Partial<EmblaOptionsType> | - |
arrows | boolean | true |
dots | boolean | true |
prevIcon | string | chevron-left/up |
nextIcon | string | chevron-right/down |
color | ColorType | 'primary' |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' |
variant | 'solid' | 'outline' | 'soft' | 'subtle' | 'ghost' | 'soft' |
ref | HTMLElement | null | null |
class | string | - |
ui | Record<Slot, Class> | - |