A carousel with motion and swipe built using Embla.
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import { Index } from "solid-js"; const CarouselDemo = () => { return ( <Carousel class="w-full max-w-xs"> <CarouselContent> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem> <div class="p-1"> <Card> <CardContent class="flex aspect-square items-center justify-center p-6"> <span class="text-4xl font-semibold">{index + 1}</span> </CardContent> </Card> </div> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> ); }; export default CarouselDemo;
npx shadcn-solid@latest add carousel
npm install @kobalte/core
import { cn } from "@/libs/cn"; import type { CreateEmblaCarouselType } from "embla-carousel-solid"; import createEmblaCarousel from "embla-carousel-solid"; import type { Accessor, ComponentProps, ParentProps, VoidProps, } from "solid-js"; import { createContext, createEffect, createMemo, createSignal, mergeProps, onCleanup, splitProps, useContext, } from "solid-js"; import { Button } from "./button"; export type CarouselApi = CreateEmblaCarouselType[1]; type UseCarouselParameters = Parameters<typeof createEmblaCarousel>; type CarouselOptions = NonNullable<UseCarouselParameters[0]>; type CarouselPlugin = NonNullable<UseCarouselParameters[1]>; type CarouselProps = { opts?: ReturnType<CarouselOptions>; plugins?: ReturnType<CarouselPlugin>; orientation?: "horizontal" | "vertical"; setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { carouselRef: ReturnType<typeof createEmblaCarousel>[0]; api: ReturnType<typeof createEmblaCarousel>[1]; scrollPrev: () => void; scrollNext: () => void; canScrollPrev: Accessor<boolean>; canScrollNext: Accessor<boolean>; } & CarouselProps; const CarouselContext = createContext<Accessor<CarouselContextProps> | null>( null, ); const useCarousel = () => { const context = useContext(CarouselContext); if (!context) { throw new Error("useCarousel must be used within a <Carousel />"); } return context(); }; export const Carousel = (props: ComponentProps<"div"> & CarouselProps) => { const merge = mergeProps< ParentProps<ComponentProps<"div"> & CarouselProps>[] >({ orientation: "horizontal" }, props); const [local, rest] = splitProps(merge, [ "orientation", "opts", "setApi", "plugins", "class", "children", ]); const [carouselRef, api] = createEmblaCarousel( () => ({ ...local.opts, axis: local.orientation === "horizontal" ? "x" : "y", }), () => (local.plugins === undefined ? [] : local.plugins), ); const [canScrollPrev, setCanScrollPrev] = createSignal(false); const [canScrollNext, setCanScrollNext] = createSignal(false); const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => { setCanScrollPrev(api.canScrollPrev()); setCanScrollNext(api.canScrollNext()); }; const scrollPrev = () => api()?.scrollPrev(); const scrollNext = () => api()?.scrollNext(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowLeft") { event.preventDefault(); scrollPrev(); } else if (event.key === "ArrowRight") { event.preventDefault(); scrollNext(); } }; createEffect(() => { if (!api() || !local.setApi) return; local.setApi(api); }); createEffect(() => { const _api = api(); if (_api === undefined) return; onSelect(_api); _api.on("reInit", onSelect); _api.on("select", onSelect); onCleanup(() => { _api.off("select", onSelect); }); }); const value = createMemo( () => ({ carouselRef, api, opts: local.opts, orientation: local.orientation || (local.opts?.axis === "y" ? "vertical" : "horizontal"), scrollPrev, scrollNext, canScrollPrev, canScrollNext, }) satisfies CarouselContextProps, ); return ( <CarouselContext.Provider value={value}> <div onKeyDown={handleKeyDown} class={cn("relative", local.class)} role="region" aria-roledescription="carousel" {...rest} > {local.children} </div> </CarouselContext.Provider> ); }; export const CarouselContent = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); const { carouselRef, orientation } = useCarousel(); return ( <div ref={carouselRef} class="overflow-hidden"> <div class={cn( "flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", local.class, )} {...rest} /> </div> ); }; export const CarouselItem = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); const { orientation } = useCarousel(); return ( <div role="group" aria-roledescription="slide" class={cn( "min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", local.class, )} {...rest} /> ); }; export const CarouselPrevious = ( props: VoidProps<ComponentProps<typeof Button>>, ) => { const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>( { variant: "outline", size: "icon" }, props, ); const [local, rest] = splitProps(merge, ["class", "variant", "size"]); const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( <Button variant={local.variant} size={local.size} class={cn( "absolute h-8 w-8 touch-manipulation rounded-full", orientation === "horizontal" ? "-left-12 top-1/2 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90", local.class, )} disabled={!canScrollPrev()} onClick={scrollPrev} {...rest} > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" > <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12l6 6m-6-6l6-6" /> <title>Previous slide</title> </svg> </Button> ); }; export const CarouselNext = ( props: VoidProps<ComponentProps<typeof Button>>, ) => { const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>( { variant: "outline", size: "icon" }, props, ); const [local, rest] = splitProps(merge, ["class", "variant", "size"]); const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( <Button variant={local.variant} size={local.size} class={cn( "absolute h-8 w-8 touch-manipulation rounded-full", orientation === "horizontal" ? "-right-12 top-1/2 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", local.class, )} disabled={!canScrollNext()} onClick={scrollNext} {...rest} > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" > <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-4 4l4-4m-4-4l4 4" /> <title>Next slide</title> </svg> </Button> ); };
import { cn } from "@/libs/cn"; import type { CreateEmblaCarouselType } from "embla-carousel-solid"; import createEmblaCarousel from "embla-carousel-solid"; import type { Accessor, ComponentProps, ParentProps, VoidProps, } from "solid-js"; import { createContext, createEffect, createMemo, createSignal, mergeProps, onCleanup, splitProps, useContext, } from "solid-js"; import { Button } from "./button"; export type CarouselApi = CreateEmblaCarouselType[1]; type UseCarouselParameters = Parameters<typeof createEmblaCarousel>; type CarouselOptions = NonNullable<UseCarouselParameters[0]>; type CarouselPlugin = NonNullable<UseCarouselParameters[1]>; type CarouselProps = { opts?: ReturnType<CarouselOptions>; plugins?: ReturnType<CarouselPlugin>; orientation?: "horizontal" | "vertical"; setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { carouselRef: ReturnType<typeof createEmblaCarousel>[0]; api: ReturnType<typeof createEmblaCarousel>[1]; scrollPrev: () => void; scrollNext: () => void; canScrollPrev: Accessor<boolean>; canScrollNext: Accessor<boolean>; } & CarouselProps; const CarouselContext = createContext<Accessor<CarouselContextProps> | null>( null, ); const useCarousel = () => { const context = useContext(CarouselContext); if (!context) { throw new Error("useCarousel must be used within a <Carousel />"); } return context(); }; export const Carousel = (props: ComponentProps<"div"> & CarouselProps) => { const merge = mergeProps< ParentProps<ComponentProps<"div"> & CarouselProps>[] >({ orientation: "horizontal" }, props); const [local, rest] = splitProps(merge, [ "orientation", "opts", "setApi", "plugins", "class", "children", ]); const [carouselRef, api] = createEmblaCarousel( () => ({ ...local.opts, axis: local.orientation === "horizontal" ? "x" : "y", }), () => (local.plugins === undefined ? [] : local.plugins), ); const [canScrollPrev, setCanScrollPrev] = createSignal(false); const [canScrollNext, setCanScrollNext] = createSignal(false); const onSelect = (api: NonNullable<ReturnType<CarouselApi>>) => { setCanScrollPrev(api.canScrollPrev()); setCanScrollNext(api.canScrollNext()); }; const scrollPrev = () => api()?.scrollPrev(); const scrollNext = () => api()?.scrollNext(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowLeft") { event.preventDefault(); scrollPrev(); } else if (event.key === "ArrowRight") { event.preventDefault(); scrollNext(); } }; createEffect(() => { if (!api() || !local.setApi) return; local.setApi(api); }); createEffect(() => { const _api = api(); if (_api === undefined) return; onSelect(_api); _api.on("reInit", onSelect); _api.on("select", onSelect); onCleanup(() => { _api.off("select", onSelect); }); }); const value = createMemo( () => ({ carouselRef, api, opts: local.opts, orientation: local.orientation || (local.opts?.axis === "y" ? "vertical" : "horizontal"), scrollPrev, scrollNext, canScrollPrev, canScrollNext, }) satisfies CarouselContextProps, ); return ( <CarouselContext.Provider value={value}> <div onKeyDown={handleKeyDown} class={cn("relative", local.class)} role="region" aria-roledescription="carousel" {...rest} > {local.children} </div> </CarouselContext.Provider> ); }; export const CarouselContent = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); const { carouselRef, orientation } = useCarousel(); return ( <div ref={carouselRef} class="overflow-hidden"> <div class={cn( "flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", local.class, )} {...rest} /> </div> ); }; export const CarouselItem = (props: ComponentProps<"div">) => { const [local, rest] = splitProps(props, ["class"]); const { orientation } = useCarousel(); return ( <div role="group" aria-roledescription="slide" class={cn( "min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", local.class, )} {...rest} /> ); }; export const CarouselPrevious = ( props: VoidProps<ComponentProps<typeof Button>>, ) => { const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>( { variant: "outline", size: "icon" }, props, ); const [local, rest] = splitProps(merge, ["class", "variant", "size"]); const { orientation, scrollPrev, canScrollPrev } = useCarousel(); return ( <Button variant={local.variant} size={local.size} class={cn( "absolute h-8 w-8 rounded-full touch-manipulation", orientation === "horizontal" ? "-left-12 top-1/2 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90", local.class, )} disabled={!canScrollPrev()} onClick={scrollPrev} {...rest} > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" > <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12l6 6m-6-6l6-6" /> <title>Previous slide</title> </svg> </Button> ); }; export const CarouselNext = ( props: VoidProps<ComponentProps<typeof Button>>, ) => { const merge = mergeProps<VoidProps<ComponentProps<typeof Button>[]>>( { variant: "outline", size: "icon" }, props, ); const [local, rest] = splitProps(merge, ["class", "variant", "size"]); const { orientation, scrollNext, canScrollNext } = useCarousel(); return ( <Button variant={local.variant} size={local.size} class={cn( "absolute h-8 w-8 rounded-full touch-manipulation", orientation === "horizontal" ? "-right-12 top-1/2 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", local.class, )} disabled={!canScrollNext()} onClick={scrollNext} {...rest} > <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" > <path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14m-4 4l4-4m-4-4l4 4" /> <title>Next slide</title> </svg> </Button> ); };
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/components/ui/carousel";
<Carousel> <CarouselContent> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel>
To set the size of the items, you can use the basis utility class on the <CarouselItem />.
basis
<CarouselItem />
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import { Index } from "solid-js"; const CarouselSize = () => { return ( <Carousel opts={{ align: "start", }} class="w-full max-w-sm" > <CarouselContent> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem class="md:basis-1/2 lg:basis-1/3"> <div class="p-1"> <Card> <CardContent class="flex aspect-square items-center justify-center p-6"> <span class="text-3xl font-semibold">{index + 1}</span> </CardContent> </Card> </div> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> ); }; export default CarouselSize;
// 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>
To set the spacing between the items, we use a pl-[VALUE] utility on the <CarouselItem /> and a negative -ml-[VALUE] on the <CarouselContent />.
pl-[VALUE]
-ml-[VALUE]
<CarouselContent />
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import { Index } from "solid-js"; const CarouselSpacing = () => { return ( <Carousel class="w-full max-w-sm"> <CarouselContent class="-ml-1"> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem class="pl-1 md:basis-1/2 lg:basis-1/3"> <div class="p-1"> <Card> <CardContent class="flex aspect-square items-center justify-center p-6"> <span class="text-2xl font-semibold">{index + 1}</span> </CardContent> </Card> </div> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> ); }; export default CarouselSpacing;
<Carousel> <CarouselContent class="-ml-4"> <CarouselItem class="pl-4">...</CarouselItem> <CarouselItem class="pl-4">...</CarouselItem> <CarouselItem class="pl-4">...</CarouselItem> </CarouselContent> </Carousel>
<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>
Use the orientation prop to set the orientation of the carousel.
orientation
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import { Index } from "solid-js"; const CarouselOrientation = () => { return ( <Carousel opts={{ align: "start", }} orientation="vertical" class="w-full max-w-xs" > <CarouselContent class="-mt-1 h-[200px]"> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem class="pt-1 md:basis-1/2"> <div class="p-1"> <Card> <CardContent class="flex items-center justify-center p-6"> <span class="text-3xl font-semibold">{index + 1}</span> </CardContent> </Card> </div> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> ); }; export default CarouselOrientation;
<Carousel orientation="vertical | horizontal"> <CarouselContent> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> </CarouselContent> </Carousel>
You can pass options to the carousel using the opts prop. See the Embla Carousel docs for more information.
opts
<Carousel opts={{ align: "start", loop: true, }} > <CarouselContent> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> </CarouselContent> </Carousel>
Use a state and the setApi props to get an instance of the carousel API.
setApi
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import { Index, createEffect, createSignal } from "solid-js"; const CarouselApiDemo = () => { const [api, setApi] = createSignal<ReturnType<CarouselApi>>(); const [current, setCurrent] = createSignal(0); const [count, setCount] = createSignal(0); const onSelect = () => { const _api = api(); if (_api === undefined) { return; } setCurrent(_api.selectedScrollSnap() + 1); }; createEffect(() => { const _api = api(); if (_api === undefined) { return; } setCount(_api.scrollSnapList().length); setCurrent(_api.selectedScrollSnap() + 1); _api.on("select", onSelect); }); return ( <div> <Carousel setApi={setApi} class="w-full max-w-xs"> <CarouselContent> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem> <Card> <CardContent class="flex aspect-square items-center justify-center p-6"> <span class="text-4xl font-semibold">{index + 1}</span> </CardContent> </Card> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> <div class="py-2 text-center text-sm text-muted-foreground"> Slide {current()} of {count()} </div> </div> ); }; export default CarouselApiDemo;
import type { CarouselApi } from "@/components/ui/carousel" export function Example() { const [api, setApi] = createSignal<CarouselApi>() const [current, setCurrent] = createSignal(0) const [count, setCount] = createSignal(0) createEffect(() => { if (!api()) { return } setCount(api().scrollSnapList().length) setCurrent(api().selectedScrollSnap() + 1) api().on("select", () => { setCurrent(api().selectedScrollSnap() + 1) }) }) return ( <Carousel setApi={setApi}> <CarouselContent> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> </CarouselContent> </Carousel> ) }
You can listen to events using the api instance from setApi.
import type { CarouselApi } from "@/components/ui/carousel" export function Example() { const [api, setApi] = createSignal<ReturnType<CarouselApi>>() const onSelect = () => { // Do something on select. } createEffect(() => { if (!api()) { return } api().on("select", onSelect) }) return ( <Carousel setApi={setApi}> <CarouselContent> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> <CarouselItem>...</CarouselItem> </CarouselContent> </Carousel> ) }
See the Embla Carousel docs for more information on using events.
You can use the plugins prop to add plugins to the carousel.
plugins
import Autoplay from "embla-carousel-autoplay" export function Example() { return ( <Carousel plugins={[ Autoplay({ delay: 2000, }), ]} > // ... </Carousel> ) }
import { Card, CardContent } from "@repo/tailwindcss/ui/card"; import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, } from "@repo/tailwindcss/ui/carousel"; import Autoplay from "embla-carousel-autoplay"; import { Index } from "solid-js"; const CarouselPlugin = () => { const autoPlayPlugin = Autoplay({ delay: 2000, stopOnInteraction: true }); return ( <Carousel plugins={[autoPlayPlugin]} class="w-full max-w-xs" onMouseEnter={autoPlayPlugin.stop} onMouseLeave={() => autoPlayPlugin.play(false)} > <CarouselContent> <Index each={Array.from({ length: 5 })}> {(_, index) => ( <CarouselItem> <div class="p-1"> <Card> <CardContent class="flex aspect-square items-center justify-center p-6"> <span class="text-4xl font-semibold">{index + 1}</span> </CardContent> </Card> </div> </CarouselItem> )} </Index> </CarouselContent> <CarouselPrevious /> <CarouselNext /> </Carousel> ); }; export default CarouselPlugin;
See the Embla Carousel docs for more information on using plugins.