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/baseCopy the component
Copy the following files into src/lib/components/ui/carousel.
It imports the button component, so copy its folder too.
<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><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><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><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><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>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>();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;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
└── CarouselNextSizes
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.
<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.
<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.
<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.