Carousel

A carousel with motion and swipe built using Embla.

Basic
1
2
3
4
5
Multiple
1
2
3
4
5
With Gap
1
2
3
4
5

About

The carousel component is built using the Embla Carousel library. It calls the framework-agnostic embla-carousel core from a Svelte attachment, so it needs no Svelte wrapper package.

Installation

Install the dependencies

pnpm add cn class-variance-authority embla-carousel github:sveltery/base

Copy the component

Copy the following files into src/lib/components/ui/carousel.

It imports the button component, so copy its folder too.

src/lib/components/ui/carousel/Carousel.svelte
<script lang="ts">
  import EmblaCarousel from 'embla-carousel';
  import { cn } from 'cn';
  import { setCarouselContext } from './context.js';
  import type { CarouselProps } from './types.js';
  let { orientation = 'horizontal', opts, plugins, api = $bindable(), class: classProp, children, ...props }: CarouselProps = $props();
  let canScrollPrev = $state(false);
  let canScrollNext = $state(false);

  setCarouselContext({
    get api() { return api; },
    get opts() { return opts; },
    get orientation() { return orientation; },
    get canScrollPrev() { return canScrollPrev; },
    get canScrollNext() { return canScrollNext; },
    scrollPrev: () => api?.scrollPrev(),
    scrollNext: () => api?.scrollNext(),
    viewport: (node) => {
      const embla = EmblaCarousel(node, { ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' }, plugins);
      const sync = () => {
        canScrollPrev = embla.canScrollPrev();
        canScrollNext = embla.canScrollNext();
      };
      sync();
      embla.on('select', sync).on('reInit', sync);
      api = embla;
      return () => {
        embla.destroy();
        api = undefined;
      };
    },
  });

  function onkeydowncapture(event: KeyboardEvent) {
    if (event.key === 'ArrowLeft') {
      event.preventDefault();
      api?.scrollPrev();
    } else if (event.key === 'ArrowRight') {
      event.preventDefault();
      api?.scrollNext();
    }
  }
</script>
<div
  {onkeydowncapture}
  class={cn('relative', classProp)}
  role="region"
  aria-roledescription="carousel"
  data-slot="carousel"
  {...props}
>
  {@render children?.()}
</div>
src/lib/components/ui/carousel/CarouselContent.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { useCarousel } from './context.js';
  import type { CarouselContentProps } from './types.js';
  let { class: classProp, children, ...props }: CarouselContentProps = $props();
  const carousel = useCarousel();
</script>
<div {@attach carousel.viewport} class="overflow-hidden" data-slot="carousel-content">
  <div class={cn('flex', carousel.orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', classProp)} {...props}>
    {@render children?.()}
  </div>
</div>
src/lib/components/ui/carousel/CarouselItem.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { useCarousel } from './context.js';
  import type { CarouselItemProps } from './types.js';
  let { class: classProp, children, ...props }: CarouselItemProps = $props();
  const carousel = useCarousel();
</script>
<div
  role="group"
  aria-roledescription="slide"
  data-slot="carousel-item"
  class={cn('min-w-0 shrink-0 grow-0 basis-full', carousel.orientation === 'horizontal' ? 'pl-4' : 'pt-4', classProp)}
  {...props}
>
  {@render children?.()}
</div>
src/lib/components/ui/carousel/CarouselNext.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { Button } from '../button/index.js';
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
  import { useCarousel } from './context.js';
  import type { CarouselNextProps } from './types.js';
  let { class: classProp, variant = 'outline', size = 'icon-sm', ...props }: CarouselNextProps = $props();
  const carousel = useCarousel();
</script>
<Button
  data-slot="carousel-next"
  {variant}
  {size}
  class={cn(
    'rounded-full absolute touch-manipulation',
    carousel.orientation === 'horizontal' ? 'inset-y-0 -right-12 my-auto' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
    classProp,
  )}
  disabled={!carousel.canScrollNext}
  onclick={carousel.scrollNext}
  {...props}
>
  <ChevronRightIcon
    class=""
  />
  <span class="sr-only">Next slide</span>
</Button>
src/lib/components/ui/carousel/CarouselPrevious.svelte
<script lang="ts">
  import { cn } from 'cn';
  import { Button } from '../button/index.js';
  import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
  import { useCarousel } from './context.js';
  import type { CarouselPreviousProps } from './types.js';
  let { class: classProp, variant = 'outline', size = 'icon-sm', ...props }: CarouselPreviousProps = $props();
  const carousel = useCarousel();
</script>
<Button
  data-slot="carousel-previous"
  {variant}
  {size}
  class={cn(
    'rounded-full absolute touch-manipulation',
    carousel.orientation === 'horizontal' ? 'inset-y-0 -left-12 my-auto' : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
    classProp,
  )}
  disabled={!carousel.canScrollPrev}
  onclick={carousel.scrollPrev}
  {...props}
>
  <ChevronLeftIcon
    class=""
  />
  <span class="sr-only">Previous slide</span>
</Button>
src/lib/components/ui/carousel/context.ts
import { createContext } from 'svelte';
import type { Attachment } from 'svelte/attachments';
import type { CarouselApi, CarouselOptions } from './types.js';

export type CarouselContextValue = {
  readonly api: CarouselApi | undefined;
  readonly opts: CarouselOptions | undefined;
  readonly orientation: 'horizontal' | 'vertical';
  readonly canScrollPrev: boolean;
  readonly canScrollNext: boolean;
  readonly viewport: Attachment<HTMLElement>;
  scrollPrev(): void;
  scrollNext(): void;
};

export const [useCarousel, setCarouselContext] = createContext<CarouselContextValue>();
src/lib/components/ui/carousel/types.ts
import type { HTMLAttributes } from 'svelte/elements';
import type { EmblaCarouselType, EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import type { ButtonProps } from '../button/index.js';

export type CarouselApi = EmblaCarouselType;
export type CarouselOptions = EmblaOptionsType;
export type CarouselPlugin = EmblaPluginType[];

export type CarouselProps = HTMLAttributes<HTMLDivElement> & {
  orientation?: 'horizontal' | 'vertical';
  opts?: CarouselOptions;
  plugins?: CarouselPlugin;
  /** The Embla instance, set once the carousel mounts. Read it with `bind:api`. */
  api?: CarouselApi;
};
export type CarouselContentProps = HTMLAttributes<HTMLDivElement>;
export type CarouselItemProps = HTMLAttributes<HTMLDivElement>;
export type CarouselPreviousProps = ButtonProps;
export type CarouselNextProps = ButtonProps;
src/lib/components/ui/carousel/index.ts
export { default as Carousel } from './Carousel.svelte';
export { default as CarouselContent } from './CarouselContent.svelte';
export { default as CarouselItem } from './CarouselItem.svelte';
export { default as CarouselPrevious } from './CarouselPrevious.svelte';
export { default as CarouselNext } from './CarouselNext.svelte';
export { useCarousel } from './context.js';
export type {
  CarouselApi, CarouselOptions, CarouselPlugin, CarouselProps, CarouselContentProps,
  CarouselItemProps, CarouselPreviousProps, CarouselNextProps,
} from './types.js';

Update the import paths

Change the imports to match your project setup.

Usage

<script lang="ts">
  import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from '$lib/components/ui/carousel';
</script>
<Carousel>
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
  <CarouselPrevious />
  <CarouselNext />
</Carousel>

Composition

Use the following composition to build a Carousel:

Carousel
├── CarouselContent
│   ├── CarouselItem
│   └── CarouselItem
├── CarouselPrevious
└── CarouselNext

Sizes

To set the size of the items, use the basis utility class on CarouselItem. The Multiple example above shows three items per view on large screens.

<!-- 33% of the carousel width. -->
<Carousel>
  <CarouselContent>
    <CarouselItem class="basis-1/3">...</CarouselItem>
    <CarouselItem class="basis-1/3">...</CarouselItem>
    <CarouselItem class="basis-1/3">...</CarouselItem>
  </CarouselContent>
</Carousel>
<!-- 50% on small screens and 33% on larger screens. -->
<Carousel>
  <CarouselContent>
    <CarouselItem class="md:basis-1/2 lg:basis-1/3">...</CarouselItem>
    <CarouselItem class="md:basis-1/2 lg:basis-1/3">...</CarouselItem>
    <CarouselItem class="md:basis-1/2 lg:basis-1/3">...</CarouselItem>
  </CarouselContent>
</Carousel>

Spacing

To set the spacing between the items, use a pl-[VALUE] utility on CarouselItem and a negative -ml-[VALUE] on CarouselContent. The With Gap example above uses pl-1 and -ml-1.

<Carousel>
  <CarouselContent class="-ml-2 md:-ml-4">
    <CarouselItem class="pl-2 md:pl-4">...</CarouselItem>
    <CarouselItem class="pl-2 md:pl-4">...</CarouselItem>
    <CarouselItem class="pl-2 md:pl-4">...</CarouselItem>
  </CarouselContent>
</Carousel>

Orientation

Use the orientation prop to set the orientation of the carousel.

1
2
3
4
5
<Carousel orientation="vertical">
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
</Carousel>

Options

Pass options to the carousel with the opts prop. See the Embla Carousel options for the full list.

<Carousel opts={{ align: 'start', loop: true }}>
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
</Carousel>

The carousel restarts Embla when opts, plugins or orientation change.

API

Use bind:api to read the Embla instance. It is undefined until the carousel mounts. Store it in $state.raw, because the instance is not data to make deeply reactive.

1
2
3
4
5
Slide 0 of 0
<script lang="ts">
  import { Carousel, CarouselContent, CarouselItem, type CarouselApi } from '$lib/components/ui/carousel';

  let api = $state.raw<CarouselApi>();
  let current = $state(0);
  let count = $state(0);

  $effect(() => {
    if (!api) return;
    const carousel = api;
    const select = () => { current = carousel.selectedScrollSnap() + 1; };
    count = carousel.scrollSnapList().length;
    select();
    carousel.on('select', select);
    return () => { carousel.off('select', select); };
  });
</script>

<Carousel bind:api>
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
</Carousel>

Events

Listen to events on the instance from bind:api. Return a cleanup from the effect so the listener goes away with the instance.

<script lang="ts">
  import type { CarouselApi } from '$lib/components/ui/carousel';

  let api = $state.raw<CarouselApi>();

  $effect(() => {
    if (!api) return;
    const onSelect = () => {
      // Do something on select.
    };
    api.on('select', onSelect);
    return () => { api?.off('select', onSelect); };
  });
</script>

See the Embla Carousel events for the full list.

Plugins

Use the plugins prop to add plugins to the carousel.

1
2
3
4
5
<script lang="ts">
  import Autoplay from 'embla-carousel-autoplay';

  const plugin = Autoplay({ delay: 2000, stopOnInteraction: true });
</script>

<Carousel plugins={[plugin]} onmouseenter={() => plugin.stop()} onmouseleave={() => plugin.reset()}>
  <!-- ... -->
</Carousel>

Keyboard

The left and right arrow keys move to the previous and next slide while focus is inside the carousel. CarouselPrevious and CarouselNext are disabled when there is no slide to move to.

RTL

When localizing the carousel for right-to-left languages, set Embla's direction option to match the text direction so the carousel scrolls the correct way.

<Carousel dir="rtl" opts={{ direction: 'rtl' }}>
  <CarouselContent>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
    <CarouselItem>...</CarouselItem>
  </CarouselContent>
  <CarouselPrevious class="rtl:rotate-180" />
  <CarouselNext class="rtl:rotate-180" />
</Carousel>

The direction option accepts ltr or rtl and should match the dir attribute. Rotate the navigation buttons with rtl:rotate-180 so they point the right way.

API reference

See the Embla Carousel API for the instance methods, options and plugins.