Skip to content
edmi-ui

Carousel

Swipeable slides built on Embla, with arrows and position dots.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Magnificent Four
Pre-IPO Basket
AI Index
Energy
Magnificent Four
Pre-IPO Basket
AI Index
Energy
Magnificent Four
Pre-IPO Basket
AI Index
Energy
Terminal window
npx shadcn@latest add @edmi-ui/carousel
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/carousel
Terminal window
yarn dlx shadcn@latest add @edmi-ui/carousel
Terminal window
bunx shadcn@latest add @edmi-ui/carousel

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/carousel
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/carousel
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/carousel
Terminal window
bunx shadcn-vue@latest add @edmi-ui/carousel

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/carousel.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/carousel.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/carousel.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/carousel.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import {
Carousel,
CarouselContent,
CarouselDots,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
<div className="mx-12 max-w-md">
<Carousel opts={{ align: "start" }}>
<CarouselContent>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
</div>
<script setup lang="ts">
import {
Carousel,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "@/components/ui/carousel";
</script>
<template>
<div class="mx-12 max-w-md">
<Carousel :opts="{ align: 'start' }">
<CarouselContent>
<CarouselItem :key="name" class="basis-1/2">
<div
:class="`flex h-32 items-end rounded-xl bg-linear-to-br p-3 text-sm font-semibold text-white ${g}`"
>
{{ name }}
</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>
</div>
</template>
<script lang="ts">
import * as Carousel from "$lib/components/ui/carousel";
</script>
<div class="mx-12 max-w-md">
<Carousel.Root opts={{ align: "start" }}>
<Carousel.Content>
{#each slides as [name, g] (name)}
<Carousel.Item class="basis-1/2">
<div
class="flex h-32 items-end rounded-xl bg-linear-to-br p-3 text-sm font-semibold text-white {g}"
>
{name}
</div>
</Carousel.Item>
{/each}
</Carousel.Content>
<Carousel.Previous />
<Carousel.Next />
<Carousel.Dots />
</Carousel.Root>
</div>
registry/ui/carousel.tsx
import { cn } from "cn";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import * as React from "react";
import { CaretLeftIcon, CaretRightIcon } from "@phosphor-icons/react";
import { Button } from "@/registry/edmi/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext],
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
{/* biome-ignore lint/a11y/useSemanticElements: matches stock shadcn carousel */}
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className,
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
// biome-ignore lint/a11y/useSemanticElements: matches stock shadcn carousel
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className,
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -left-12 my-auto"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<CaretLeftIcon
className="rtl:rotate-180"
/>
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon-sm",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute touch-manipulation rounded-full",
orientation === "horizontal"
? "inset-y-0 -right-12 my-auto"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className,
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<CaretRightIcon
className="rtl:rotate-180"
/>
<span className="sr-only">Next slide</span>
</Button>
);
}
// ✦ Position dots: the active slide is a wider --foreground pill.
function CarouselDots({ className, ...props }: React.ComponentProps<"div">) {
const { api } = useCarousel();
const [count, setCount] = React.useState(0);
const [selected, setSelected] = React.useState(0);
React.useEffect(() => {
if (!api) return;
const update = () => {
setCount(api.scrollSnapList().length);
setSelected(api.selectedScrollSnap());
};
update();
api.on("reInit", update);
api.on("select", update);
return () => {
api.off("reInit", update);
api.off("select", update);
};
}, [api]);
return (
<div
data-slot="carousel-dots"
className={cn("mt-3 flex items-center justify-center gap-1.5", className)}
{...props}
>
{Array.from({ length: count }, (_, i) => (
<button
// biome-ignore lint/suspicious/noArrayIndexKey: dots are positional
key={i}
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={i === selected}
onClick={() => api?.scrollTo(i)}
className={cn(
"h-1.5 rounded-full outline-none transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
i === selected ? "w-6 bg-foreground" : "w-1.5 bg-input",
)}
/>
))}
</div>
);
}
export {
Carousel,
type CarouselApi,
CarouselContent,
CarouselDots,
CarouselItem,
CarouselNext,
CarouselPrevious,
useCarousel,
};
registry/ui/carousel/Carousel.vue
<script setup lang="ts">
import type { CarouselEmits, CarouselProps, WithClassAsProps } from "./interface"
import { cn } from "@/registry/edmi/lib/utils"
import { useProvideCarousel } from "./useCarousel"
const props = withDefaults(defineProps<CarouselProps & WithClassAsProps>(), {
orientation: "horizontal",
})
const emits = defineEmits<CarouselEmits>()
const { canScrollNext, canScrollPrev, carouselApi, carouselRef, orientation, scrollNext, scrollPrev } = useProvideCarousel(props, emits)
defineExpose({
canScrollNext,
canScrollPrev,
carouselApi,
carouselRef,
orientation,
scrollNext,
scrollPrev,
})
function onKeyDown(event: KeyboardEvent) {
const prevKey = props.orientation === "vertical" ? "ArrowUp" : "ArrowLeft"
const nextKey = props.orientation === "vertical" ? "ArrowDown" : "ArrowRight"
if (event.key === prevKey) {
event.preventDefault()
scrollPrev()
return
}
if (event.key === nextKey) {
event.preventDefault()
scrollNext()
}
}
</script>
<template>
<div
data-slot="carousel"
:class="cn('relative outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring', props.class)"
role="region"
aria-roledescription="carousel"
tabindex="0"
@keydown="onKeyDown"
>
<slot :can-scroll-next :can-scroll-prev :carousel-api :carousel-ref :orientation :scroll-next :scroll-prev />
</div>
</template>
registry/ui/carousel/CarouselContent.vue
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import { cn } from "@/registry/edmi/lib/utils"
import { useCarousel } from "./useCarousel"
defineOptions({
inheritAttrs: false,
})
const props = defineProps<WithClassAsProps>()
// `carouselRef` is bound through the template ref below.
const { carouselRef, orientation } = useCarousel()
void carouselRef
</script>
<template>
<div
ref="carouselRef"
data-slot="carousel-content"
class="overflow-hidden"
>
<div
:class="
cn(
'flex',
orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col',
props.class,
)"
v-bind="$attrs"
>
<slot />
</div>
</div>
</template>
registry/ui/carousel/CarouselDots.vue
<script setup lang="ts">
import type { HTMLAttributes } from 'vue'
import type { UnwrapRefCarouselApi } from './interface'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { cn } from '@/registry/edmi/lib/utils'
import { useCarousel } from './useCarousel'
// ✦ Position dots: the active slide is a wider --foreground pill.
const props = defineProps<{ class?: HTMLAttributes['class'] }>()
const { carouselApi } = useCarousel()
const count = ref(0)
const selected = ref(0)
const slides = computed(() => Array.from({ length: count.value }, (_, i) => i))
function update(api: UnwrapRefCarouselApi) {
if (!api)
return
count.value = api.scrollSnapList().length
selected.value = api.selectedScrollSnap()
}
let off: (() => void) | undefined
watch(
carouselApi,
(api) => {
off?.()
off = undefined
if (!api)
return
const handler = () => update(api)
handler()
api.on('reInit', handler)
api.on('select', handler)
off = () => {
api.off('reInit', handler)
api.off('select', handler)
}
},
{ immediate: true },
)
onBeforeUnmount(() => off?.())
</script>
<template>
<div
data-slot="carousel-dots"
:class="cn('mt-3 flex items-center justify-center gap-1.5', props.class)"
>
<button
v-for="i in slides"
:key="i"
type="button"
:aria-label="`Go to slide ${i + 1}`"
:aria-current="i === selected"
:class="cn(
'h-1.5 rounded-full outline-none transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
i === selected ? 'w-6 bg-foreground' : 'w-1.5 bg-input',
)"
@click="carouselApi?.scrollTo(i)"
/>
</div>
</template>
registry/ui/carousel/CarouselItem.vue
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import { cn } from "@/registry/edmi/lib/utils"
import { useCarousel } from "./useCarousel"
const props = defineProps<WithClassAsProps>()
const { orientation } = useCarousel()
</script>
<template>
<div
data-slot="carousel-item"
role="group"
aria-roledescription="slide"
:class="cn(
'min-w-0 shrink-0 grow-0 basis-full',
orientation === 'horizontal' ? 'pl-4' : 'pt-4',
props.class,
)"
>
<slot />
</div>
</template>
registry/ui/carousel/CarouselNext.vue
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import type { ButtonVariants } from "@/registry/edmi/ui/button"
import { ChevronRight } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { useCarousel } from "./useCarousel"
const props = withDefaults(defineProps<{
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}
& WithClassAsProps>(), {
variant: "outline",
size: "icon-sm",
})
const { orientation, canScrollNext, scrollNext } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-next"
:disabled="!canScrollNext"
:class="cn(
'absolute touch-manipulation rounded-full',
orientation === 'horizontal'
? 'inset-y-0 -right-12 my-auto'
: '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
:variant="variant"
:size="size"
@click="scrollNext"
>
<slot>
<ChevronRight class="rtl:rotate-180" />
<span class="sr-only">Next slide</span>
</slot>
</Button>
</template>
registry/ui/carousel/CarouselPrevious.vue
<script setup lang="ts">
import type { WithClassAsProps } from "./interface"
import type { ButtonVariants } from "@/registry/edmi/ui/button"
import { ChevronLeft } from "@lucide/vue"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import { useCarousel } from "./useCarousel"
const props = withDefaults(defineProps<{
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
}
& WithClassAsProps>(), {
variant: "outline",
size: "icon-sm",
})
const { orientation, canScrollPrev, scrollPrev } = useCarousel()
</script>
<template>
<Button
data-slot="carousel-previous"
:disabled="!canScrollPrev"
:class="cn(
'absolute touch-manipulation rounded-full',
orientation === 'horizontal'
? 'inset-y-0 -left-12 my-auto'
: '-top-12 left-1/2 -translate-x-1/2 rotate-90',
props.class,
)"
:variant="variant"
:size="size"
@click="scrollPrev"
>
<slot>
<ChevronLeft class="rtl:rotate-180" />
<span class="sr-only">Previous slide</span>
</slot>
</Button>
</template>
registry/ui/carousel/index.ts
export { default as Carousel } from "./Carousel.vue";
export { default as CarouselContent } from "./CarouselContent.vue";
export { default as CarouselDots } from "./CarouselDots.vue";
export { default as CarouselItem } from "./CarouselItem.vue";
export { default as CarouselNext } from "./CarouselNext.vue";
export { default as CarouselPrevious } from "./CarouselPrevious.vue";
export type { UnwrapRefCarouselApi as CarouselApi } from "./interface";
export { useCarousel } from "./useCarousel";
registry/ui/carousel/interface.ts
import type useEmblaCarousel from "embla-carousel-vue";
import type { EmblaCarouselVueType } from "embla-carousel-vue";
import type { HTMLAttributes, UnwrapRef } from "vue";
type CarouselApi = EmblaCarouselVueType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
export type UnwrapRefCarouselApi = UnwrapRef<CarouselApi>;
export interface CarouselProps {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
}
export type CarouselEmits = (
e: "init-api",
payload: UnwrapRefCarouselApi,
) => void;
export interface WithClassAsProps {
class?: HTMLAttributes["class"];
}
registry/ui/carousel/useCarousel.ts
import { createInjectionState } from "@vueuse/core";
import emblaCarouselVue from "embla-carousel-vue";
import { onMounted, ref } from "vue";
import type {
UnwrapRefCarouselApi as CarouselApi,
CarouselEmits,
CarouselProps,
} from "./interface";
const [useProvideCarousel, useInjectCarousel] = createInjectionState(
({ opts, orientation, plugins }: CarouselProps, emits: CarouselEmits) => {
const [emblaNode, emblaApi] = emblaCarouselVue(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins,
);
function scrollPrev() {
emblaApi.value?.scrollPrev();
}
function scrollNext() {
emblaApi.value?.scrollNext();
}
const canScrollNext = ref(false);
const canScrollPrev = ref(false);
function onSelect(api: CarouselApi) {
canScrollNext.value = api?.canScrollNext() || false;
canScrollPrev.value = api?.canScrollPrev() || false;
}
onMounted(() => {
if (!emblaApi.value) return;
emblaApi.value?.on("init", onSelect);
emblaApi.value?.on("reInit", onSelect);
emblaApi.value?.on("select", onSelect);
emits("init-api", emblaApi.value);
});
return {
carouselRef: emblaNode,
carouselApi: emblaApi,
canScrollPrev,
canScrollNext,
scrollPrev,
scrollNext,
orientation,
};
},
);
function useCarousel() {
const carouselState = useInjectCarousel();
if (!carouselState)
throw new Error("useCarousel must be used within a <Carousel />");
return carouselState;
}
export { useCarousel, useProvideCarousel };
src/lib/registry/ui/carousel/carousel.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import {
type CarouselAPI,
type CarouselProps,
type EmblaContext,
setEmblaContext,
} from "./context.js";
let {
ref = $bindable(null),
opts = {},
plugins = [],
setApi = () => {},
orientation = "horizontal",
class: className,
children,
...restProps
}: WithElementRef<CarouselProps> = $props();
// svelte-ignore state_referenced_locally
let carouselState = $state<EmblaContext>({
api: undefined,
scrollPrev,
scrollNext,
orientation,
canScrollNext: false,
canScrollPrev: false,
handleKeyDown,
options: opts,
plugins,
onInit,
scrollSnaps: [],
selectedIndex: 0,
scrollTo,
});
setEmblaContext(carouselState);
function scrollPrev() {
carouselState.api?.scrollPrev();
}
function scrollNext() {
carouselState.api?.scrollNext();
}
function scrollTo(index: number, jump?: boolean) {
carouselState.api?.scrollTo(index, jump);
}
function onSelect() {
if (!carouselState.api) return;
carouselState.selectedIndex = carouselState.api.selectedScrollSnap();
carouselState.canScrollNext = carouselState.api.canScrollNext();
carouselState.canScrollPrev = carouselState.api.canScrollPrev();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") {
e.preventDefault();
scrollPrev();
} else if (e.key === "ArrowRight") {
e.preventDefault();
scrollNext();
}
}
function onInit(event: CustomEvent<CarouselAPI>) {
carouselState.api = event.detail;
setApi(carouselState.api);
carouselState.scrollSnaps = carouselState.api.scrollSnapList();
carouselState.api.on("select", onSelect);
onSelect();
}
$effect(() => {
return () => {
carouselState.api?.off("select", onSelect);
};
});
</script>
<div
bind:this={ref}
data-slot="carousel"
class={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/carousel/carousel-content.svelte
<script lang="ts">
import emblaCarouselSvelte from "embla-carousel-svelte";
import { cn, type WithElementRef } from "$lib/utils.js";
import { getEmblaContext } from "./context.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const emblaCtx = getEmblaContext("<Carousel.Content/>");
</script>
<div
data-slot="carousel-content"
class="overflow-hidden"
use:emblaCarouselSvelte={{
options: {
container: "[data-embla-container]",
slides: "[data-embla-slide]",
...emblaCtx.options,
axis: emblaCtx.orientation === "horizontal" ? "x" : "y",
},
plugins: emblaCtx.plugins,
}}
onemblaInit={emblaCtx.onInit}
>
<div
bind:this={ref}
class={cn(
"flex",
emblaCtx.orientation === "horizontal" ? "-ms-4" : "-mt-4 flex-col",
className
)}
data-embla-container=""
{...restProps}
>
{@render children?.()}
</div>
</div>
src/lib/registry/ui/carousel/carousel-dots.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { getEmblaContext } from "./context.js";
import type { HTMLAttributes } from "svelte/elements";
// ✦ Position dots: the active slide is a wider --foreground pill.
let {
ref = $bindable(null),
class: className,
...restProps
}: WithElementRef<Omit<HTMLAttributes<HTMLDivElement>, "children">> = $props();
const emblaCtx = getEmblaContext("<Carousel.Dots/>");
</script>
<div
bind:this={ref}
data-slot="carousel-dots"
class={cn("mt-3 flex items-center justify-center gap-1.5", className)}
{...restProps}
>
{#each emblaCtx.scrollSnaps as _, i (i)}
<button
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={i === emblaCtx.selectedIndex}
onclick={() => emblaCtx.scrollTo(i)}
class={cn(
"h-1.5 rounded-full outline-none transition-all focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
i === emblaCtx.selectedIndex ? "w-6 bg-foreground" : "w-1.5 bg-input"
)}
></button>
{/each}
</div>
src/lib/registry/ui/carousel/carousel-item.svelte
<script lang="ts">
import { cn, type WithElementRef } from "$lib/utils.js";
import { getEmblaContext } from "./context.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
const emblaCtx = getEmblaContext("<Carousel.Item/>");
</script>
<div
bind:this={ref}
data-slot="carousel-item"
role="group"
aria-roledescription="slide"
class={cn(
"min-w-0 shrink-0 grow-0 basis-full",
emblaCtx.orientation === "horizontal" ? "ps-4" : "pt-4",
className
)}
data-embla-slide=""
{...restProps}
>
{@render children?.()}
</div>
src/lib/registry/ui/carousel/carousel-next.svelte
<script lang="ts">
import CaretRightIcon from "phosphor-svelte/lib/CaretRight";
import { Button, type Props } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { getEmblaContext } from "./context.js";
import type { WithoutChildren } from "bits-ui";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "icon-sm",
...restProps
}: WithoutChildren<Props> = $props();
const emblaCtx = getEmblaContext("<Carousel.Next/>");
</script>
<Button
data-slot="carousel-next"
{variant}
{size}
aria-disabled={!emblaCtx.canScrollNext}
disabled={!emblaCtx.canScrollNext}
class={cn(
"absolute touch-manipulation rounded-full",
emblaCtx.orientation === "horizontal"
? "inset-y-0 -end-12 my-auto"
: "start-1/2 -bottom-12 -translate-x-1/2 rotate-90",
className
)}
onclick={emblaCtx.scrollNext}
onkeydown={emblaCtx.handleKeyDown}
bind:ref
{...restProps}
>
<CaretRightIcon
class="rtl:rotate-180"
/>
<span class="sr-only">Next slide</span>
</Button>
src/lib/registry/ui/carousel/carousel-previous.svelte
<script lang="ts">
import CaretLeftIcon from "phosphor-svelte/lib/CaretLeft";
import { Button, type Props } from "$lib/registry/ui/button/index.js";
import { cn } from "$lib/utils.js";
import { getEmblaContext } from "./context.js";
import type { WithoutChildren } from "bits-ui";
let {
ref = $bindable(null),
class: className,
variant = "outline",
size = "icon-sm",
...restProps
}: WithoutChildren<Props> = $props();
const emblaCtx = getEmblaContext("<Carousel.Previous/>");
</script>
<Button
data-slot="carousel-previous"
{variant}
{size}
aria-disabled={!emblaCtx.canScrollPrev}
disabled={!emblaCtx.canScrollPrev}
class={cn(
"absolute touch-manipulation rounded-full",
emblaCtx.orientation === "horizontal"
? "inset-y-0 -start-12 my-auto"
: "start-1/2 -top-12 -translate-x-1/2 rotate-90",
className
)}
onclick={emblaCtx.scrollPrev}
onkeydown={emblaCtx.handleKeyDown}
{...restProps}
bind:ref
>
<CaretLeftIcon
class="rtl:rotate-180"
/>
<span class="sr-only">Previous slide</span>
</Button>
src/lib/registry/ui/carousel/context.ts
import type {
EmblaCarouselSvelteType,
default as emblaCarouselSvelte,
} from "embla-carousel-svelte";
import { getContext, hasContext, setContext } from "svelte";
import type { HTMLAttributes } from "svelte/elements";
import type { WithElementRef } from "$lib/utils.js";
export type CarouselAPI =
NonNullable<
NonNullable<EmblaCarouselSvelteType["$$_attributes"]>["on:emblaInit"]
> extends (evt: CustomEvent<infer CarouselAPI>) => void
? CarouselAPI
: never;
type EmblaCarouselConfig = NonNullable<
Parameters<typeof emblaCarouselSvelte>[1]
>;
export type CarouselOptions = EmblaCarouselConfig["options"];
export type CarouselPlugins = EmblaCarouselConfig["plugins"];
////
export type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugins;
setApi?: (api: CarouselAPI | undefined) => void;
orientation?: "horizontal" | "vertical";
} & WithElementRef<HTMLAttributes<HTMLDivElement>>;
const EMBLA_CAROUSEL_CONTEXT = Symbol("EMBLA_CAROUSEL_CONTEXT");
export type EmblaContext = {
api: CarouselAPI | undefined;
orientation: "horizontal" | "vertical";
scrollNext: () => void;
scrollPrev: () => void;
canScrollNext: boolean;
canScrollPrev: boolean;
handleKeyDown: (e: KeyboardEvent) => void;
options: CarouselOptions;
plugins: CarouselPlugins;
onInit: (e: CustomEvent<CarouselAPI>) => void;
scrollTo: (index: number, jump?: boolean) => void;
scrollSnaps: number[];
selectedIndex: number;
};
export function setEmblaContext(config: EmblaContext): EmblaContext {
setContext(EMBLA_CAROUSEL_CONTEXT, config);
return config;
}
export function getEmblaContext(name = "This component") {
if (!hasContext(EMBLA_CAROUSEL_CONTEXT)) {
throw new Error(`${name} must be used within a <Carousel.Root> component`);
}
return getContext<ReturnType<typeof setEmblaContext>>(EMBLA_CAROUSEL_CONTEXT);
}
src/lib/registry/ui/carousel/index.ts
import Root from "./carousel.svelte";
import Content from "./carousel-content.svelte";
import Dots from "./carousel-dots.svelte";
import Item from "./carousel-item.svelte";
import Next from "./carousel-next.svelte";
import Previous from "./carousel-previous.svelte";
export {
Content,
Content as CarouselContent,
Dots,
Dots as CarouselDots,
Item,
Item as CarouselItem,
Next,
Next as CarouselNext,
Previous,
Previous as CarouselPrevious,
Root,
//
Root as Carousel,
};
Prop Type Default
opts Embla options —
plugins Embla plugins —
orientation "horizontal" | "vertical" "horizontal"
setApi (api: CarouselApi) => void —

Anatomy: CarouselContent, CarouselItem, CarouselPrevious, CarouselNext.

  • CarouselDots: position dots (active slide is a wider pill).
  • CarouselDots is shipped in all three frameworks.
  • Vue: the carousel emits init-api, the equivalent of React’s setApi.
  • Svelte: pass a setApi prop, as in React.