Carousel

Scrollable content carousel with navigation controls.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/carousel.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

"use client"; import { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, } from "react"; import useEmblaCarousel, { type UseEmblaCarouselType, } from "embla-carousel-react"; import { ArrowLeft, ArrowRight } from "lucide-react"; import { cn } from "../../lib/utils"; import { Button } from "../button/button"; type CarouselApi = UseEmblaCarouselType[1]; type UseCarouselParameters = Parameters<typeof useEmblaCarousel>; type CarouselOptions = UseCarouselParameters[0]; type CarouselPlugin = UseCarouselParameters[1]; type CarouselProps = { opts?: CarouselOptions; orientation?: "horizontal" | "vertical"; plugins?: CarouselPlugin; setApi?: (api: CarouselApi) => void; }; type CarouselContextProps = { api: ReturnType<typeof useEmblaCarousel>[1]; canScrollNext: boolean; canScrollPrev: boolean; carouselRef: ReturnType<typeof useEmblaCarousel>[0]; scrollNext: () => void; scrollPrev: () => void; } & CarouselProps; const CarouselContext = createContext<CarouselContextProps | null>(null); function useCarousel() { const context = useContext(CarouselContext); if (!context) { throw new Error("useCarousel must be used within a <Carousel />"); } return context; } type UseCarouselLogicOptions = { options: CarouselOptions; orientation: "horizontal" | "vertical"; plugins: CarouselPlugin; setApi?: (api: CarouselApi) => void; }; function useCarouselLogic({ options, orientation, plugins, setApi, }: UseCarouselLogicOptions) { const [carouselRef, api] = useEmblaCarousel( { ...options, axis: orientation === "horizontal" ? "x" : "y", }, plugins, ); const [canScrollPrevious, setCanScrollPrevious] = useState(false); const [canScrollNext, setCanScrollNext] = useState(false); const onSelect = useCallback((api: CarouselApi) => { if (!api) { return; } setCanScrollPrevious(api.canScrollPrev()); setCanScrollNext(api.canScrollNext()); }, []); const onSelectReference = useRef(onSelect); useEffect(() => { onSelectReference.current = onSelect; }, [onSelect]); const scrollPrevious = useCallback(() => { api?.scrollPrev(); }, [api]); const scrollNext = useCallback(() => { api?.scrollNext(); }, [api]); const handleKeyDown = useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { if (event.key === "ArrowLeft") { event.preventDefault(); scrollPrevious(); } else if (event.key === "ArrowRight") { event.preventDefault(); scrollNext(); } }, [scrollPrevious, scrollNext], ); useEffect(() => { if (!api || !setApi) { return; } setApi(api); }, [api, setApi]); useEffect(() => { if (!api) { return; } const notifySelection = (selectedApi: CarouselApi) => { onSelectReference.current(selectedApi); }; api.on("reInit", notifySelection); api.on("select", notifySelection); const rafId = requestAnimationFrame(() => { notifySelection(api); }); return () => { api?.off("select", notifySelection); api?.off("reInit", notifySelection); cancelAnimationFrame(rafId); }; }, [api]); return { api, canScrollNext, canScrollPrevious, carouselRef, handleKeyDown, scrollNext, scrollPrevious, }; } const Carousel = forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps >( ( { children, className, opts, orientation = "horizontal", plugins, setApi, ...props }, ref, ) => { const { api, canScrollNext, canScrollPrevious, carouselRef, handleKeyDown, scrollNext, scrollPrevious, } = useCarouselLogic({ options: opts, orientation, plugins, setApi }); const contextValue = useMemo( () => ({ api, canScrollNext, canScrollPrev: canScrollPrevious, carouselRef, opts, orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"), scrollNext, scrollPrev: scrollPrevious, }), [ api, canScrollNext, canScrollPrevious, carouselRef, opts, orientation, scrollNext, scrollPrevious, ], ); return ( <CarouselContext.Provider value={contextValue}> <div aria-roledescription="carousel" className={cn("relative", className)} onKeyDownCapture={handleKeyDown} ref={ref} role="region" {...props} > {children} </div> </CarouselContext.Provider> ); }, ); Carousel.displayName = "Carousel"; const CarouselContent = forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { carouselRef, orientation } = useCarousel(); return ( <div className="overflow-hidden" ref={carouselRef}> <div className={cn( "flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className, )} ref={ref} {...props} /> </div> ); }); CarouselContent.displayName = "CarouselContent"; const CarouselItem = forwardRef< HTMLDivElement, React.HTMLAttributes<HTMLDivElement> >(({ className, ...props }, ref) => { const { orientation } = useCarousel(); return ( <div aria-roledescription="slide" className={cn( "min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className, )} ref={ref} role="group" {...props} /> ); }); CarouselItem.displayName = "CarouselItem"; const CarouselPrevious = forwardRef< HTMLButtonElement, React.ComponentProps<typeof Button> >(({ className, size = "icon", variant = "outline", ...props }, ref) => { const { canScrollPrev, orientation, scrollPrev } = useCarousel(); return ( <Button className={cn( "absolute size-8 rounded-full", orientation === "horizontal" ? "-left-12 top-1/2 -translate-y-1/2" : "-top-12 left-1/2 -translate-x-1/2 rotate-90", className, )} disabled={!canScrollPrev} onClick={scrollPrev} ref={ref} size={size} variant={variant} {...props} > <ArrowLeft className="size-4" /> <span className="sr-only">Previous slide</span> </Button> ); }); CarouselPrevious.displayName = "CarouselPrevious"; const CarouselNext = forwardRef< HTMLButtonElement, React.ComponentProps<typeof Button> >(({ className, size = "icon", variant = "outline", ...props }, ref) => { const { canScrollNext, orientation, scrollNext } = useCarousel(); return ( <Button className={cn( "absolute size-8 rounded-full", orientation === "horizontal" ? "-right-12 top-1/2 -translate-y-1/2" : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90", className, )} disabled={!canScrollNext} onClick={scrollNext} ref={ref} size={size} variant={variant} {...props} > <ArrowRight className="size-4" /> <span className="sr-only">Next slide</span> </Button> ); }); CarouselNext.displayName = "CarouselNext"; export { Carousel, type CarouselApi, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, };

Dependencies

  • @vllnt/ui@^0.2.1