Data Display

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.

Playground

Toggle props in real-time. Drag, tap arrows, or click a dot to navigate.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5

Basic Usage

Pass an items array and render each via the slide snippet. Arrows and dots show by default.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<!-- 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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<!-- 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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<!-- 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.

OptionTypeDefault
delaynumber4000
stopOnInteractionbooleanfalse
stopOnMouseEnterbooleantrue
stopOnFocusInbooleantrue
stopOnLastSnapbooleanfalse
playOnInitbooleantrue

Fade Transition

Cross-fade between slides instead of sliding. Pairs well with image galleries.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<!-- 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.

Card 1
Card 2
Card 3
Card 4
Card 5
Card 6
Card 7
Card 8
<!-- 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.

Card 1
Card 2
Card 3
Card 4
Card 5
Card 6
Card 7
Card 8
<!-- 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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<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.

Card 1
Card 2
Card 3
Card 4
Card 5
Card 6
Card 7
Card 8
<!-- 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.

Fast

Lightweight, GPU-accelerated transitions powered by Embla.

Flexible

Slides per view, breakpoints, autoplay, fade — all opt-in.

Accessible

Full keyboard support and ARIA wiring out of the box.

Composable

Bring your own slide, dot, and arrow snippets.

<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() }.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<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() }.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
<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
Slide 2
Slide 3
Slide 4
Slide 5

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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
Jump to:
<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.

Photo 1
Photo 2
Photo 3
Photo 4
Photo 5
Photo 6
Photo 7
Photo 8
<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.

Photo 1
Photo 2
Photo 3
Photo 4
Photo 5
Photo 6
Photo 7
Photo 8
<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.

SlotDescription
rootOutermost wrapper around viewport, arrows, and dots
viewportClipping container (overflow-hidden) that Embla observes
containerFlex track that holds the slides and is moved by Embla
slideWrapper around each item — controls `flex-basis` for `slidesToShow`
arrowShared classes applied to both arrow buttons
arrowPrevPrevious arrow button position
arrowNextNext arrow button position
dotsPagination dots container
dotEach pagination dot button

Snippets

SnippetDescription
slideRender each item. Receives `{ item, index, selected }`
dotCustom dot. Receives `{ index, selected, select() }`
prevSlotCustom previous arrow. Receives `{ canScroll, scroll() }`
nextSlotCustom next arrow. Receives `{ canScroll, scroll() }`
childrenFallback when `items` is not provided — pass slide elements directly

Props

PropTypeDefault
itemsT[]-
indexnumber0
onIndexChange(index: number) => void-
onSettle(api: CarouselApi) => void-
apiCarouselApi | undefined-
slidesToShownumber1
slidesToScrollnumber | 'auto'1
loopbooleanfalse
align'start' | 'center' | 'end''start'
orientation'horizontal' | 'vertical''horizontal'
draggablebooleantrue
dragFreebooleanfalse
autoplayboolean | CarouselAutoplayOptionsfalse
fadebooleanfalse
classNamesbooleanfalse
breakpointsRecord<string, EmblaOptionsType>-
pluginsEmblaPluginType[]-
optionsPartial<EmblaOptionsType>-
arrowsbooleantrue
dotsbooleantrue
prevIconstringchevron-left/up
nextIconstringchevron-right/down
colorColorType'primary'
size'xs' | 'sm' | 'md' | 'lg' | 'xl''md'
variant'solid' | 'outline' | 'soft' | 'subtle' | 'ghost''soft'
refHTMLElement | nullnull
classstring-
uiRecord<Slot, Class>-