UIPackage

Carousel

React data-display
Edit on GitHub

Horizontal or vertical scroller with previous/next controls. Built on embla-carousel-react (the official shadcn carousel pattern) and wired through a React context. Drop in images, cards, or any custom slide content.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://react.uipkge.dev/r/react/carousel.json

Or with the named registry: npx shadcn@latest add @uipkge-react/carousel

Examples

Props

Name Type / Values Default Required
opts CarouselOptions optional
plugins CarouselPlugin optional
orientation 'horizontal' | 'vertical' horizontal optional
setApi (api: CarouselApi) => void optional

Dependencies

Files (2)

  • components/ui/carousel/carousel.tsx 11 kB
    'use client'
    
    /**
     * Carousel
     *
     * A flexible carousel / slider for cycling through content, built on
     * embla-carousel-react (the official shadcn carousel pattern). All parts are
     * consolidated into this single file and wired through a React context that
     * holds the embla api.
     *
     * Tailwind class strings are kept 1:1 with the Vue registry's carousel so the
     * two frameworks render identically.
     *
     * @example - Basic carousel with images
     * <Carousel>
     *   <CarouselContent>
     *     {images.map((img) => (
     *       <CarouselItem key={img.id}>
     *         <img src={img.src} alt={img.alt} className="w-full h-full object-cover" />
     *       </CarouselItem>
     *     ))}
     *   </CarouselContent>
     * </Carousel>
     *
     * @example - Carousel with navigation controls
     * <Carousel opts={{ loop: true }}>
     *   <CarouselContent>
     *     {slides.map((slide) => (
     *       <CarouselItem key={slide.id}>
     *         <SlideContent content={slide} />
     *       </CarouselItem>
     *     ))}
     *   </CarouselContent>
     *   <CarouselPrevious />
     *   <CarouselNext />
     * </Carousel>
     *
     * @example - Vertical carousel
     * <Carousel orientation="vertical">
     *   <CarouselContent>
     *     {items.map((item) => (
     *       <CarouselItem key={item}>{item}</CarouselItem>
     *     ))}
     *   </CarouselContent>
     * </Carousel>
     */
    import * as React from 'react'
    import useEmblaCarousel, {
      type UseEmblaCarouselType,
    } from 'embla-carousel-react'
    import { ChevronLeft, ChevronRight } from 'lucide-react'
    
    import { cn } from '@/lib/utils'
    import { Button } from '@/components/ui/button'
    
    type CarouselApi = UseEmblaCarouselType[1]
    type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
    type CarouselOptions = UseCarouselParameters[0]
    type CarouselPlugin = UseCarouselParameters[1]
    
    export interface CarouselProps {
      opts?: CarouselOptions
      plugins?: CarouselPlugin
      orientation?: 'horizontal' | 'vertical'
      setApi?: (api: CarouselApi) => void
    }
    
    interface CarouselContextProps extends CarouselProps {
      carouselRef: ReturnType<typeof useEmblaCarousel>[0]
      api: CarouselApi
      scrollPrev: () => void
      scrollNext: () => void
      canScrollPrev: boolean
      canScrollNext: boolean
      orientation: 'horizontal' | 'vertical'
    }
    
    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
    }
    
    const Carousel = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement> & CarouselProps
    >(
      (
        {
          orientation = 'horizontal',
          opts,
          setApi,
          plugins,
          className,
          children,
          ...props
        },
        ref,
      ) => {
        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,
              opts,
              orientation:
                orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
              scrollPrev,
              scrollNext,
              canScrollPrev,
              canScrollNext,
            }}
          >
            <div
              ref={ref}
              data-uipkge=""
              data-slot="carousel"
              onKeyDownCapture={handleKeyDown}
              className={cn(
                'relative overflow-hidden',
                orientation === 'horizontal' ? 'w-full' : 'h-full flex-col',
                className,
              )}
              role="region"
              aria-roledescription="carousel"
              aria-label="Carousel"
              {...props}
            >
              {children}
            </div>
          </CarouselContext.Provider>
        )
      },
    )
    Carousel.displayName = 'Carousel'
    
    const CarouselContent = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement>
    >(({ className, ...props }, ref) => {
      const { carouselRef, orientation } = useCarousel()
    
      return (
        <div
          ref={carouselRef}
          data-uipkge=""
          data-slot="carousel-content"
          className={cn(
            orientation === 'horizontal'
              ? 'flex overflow-x-auto scroll-smooth snap-x snap-mandatory'
              : 'flex flex-col overflow-y-auto snap-y snap-mandatory',
            'relative h-full w-full',
            '[-ms-overflow-style:none] [scrollbar-width:none]',
            '[&::-webkit-scrollbar]:hidden',
          )}
          aria-orientation={orientation}
          role="group"
        >
          <div
            ref={ref}
            className={cn(
              'flex',
              orientation === 'horizontal' ? '' : 'flex-col',
              className,
            )}
            {...props}
          />
        </div>
      )
    })
    CarouselContent.displayName = 'CarouselContent'
    
    const CarouselItem = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement>
    >(({ className, ...props }, ref) => {
      const { orientation } = useCarousel()
    
      return (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="carousel-item"
          className={cn(
            'flex shrink-0 grow-0 basis-full flex-col',
            orientation === 'horizontal' ? 'w-full' : 'h-full',
            'snap-start',
            'relative',
            className,
          )}
          role="group"
          aria-roledescription="slide"
          {...props}
        />
      )
    })
    CarouselItem.displayName = 'CarouselItem'
    
    const CarouselPrevious = React.forwardRef<
      HTMLButtonElement,
      React.ComponentProps<typeof Button> & { label?: string }
    >(
      (
        { className, variant = 'outline', size = 'icon', label = 'Previous slide', children, ...props },
        ref,
      ) => {
        const { orientation, scrollPrev, canScrollPrev } = useCarousel()
        const isHorizontal = orientation !== 'vertical'
    
        return (
          <Button
            ref={ref}
            type="button"
            variant={variant}
            size={size}
            className={cn(
              'absolute z-10 size-8 shrink-0 rounded-full',
              'bg-background/80 border shadow-md backdrop-blur-sm',
              'hover:bg-accent hover:text-accent-foreground',
              'disabled:pointer-events-none disabled:opacity-50',
              isHorizontal
                ? 'top-1/2 -left-3 -translate-y-1/2'
                : '-top-3 left-1/2 -translate-x-1/2 rotate-90',
              className,
            )}
            disabled={!canScrollPrev}
            aria-label={label}
            onClick={scrollPrev}
            {...props}
          >
            {children ?? <ChevronLeft className="size-4" aria-hidden="true" />}
          </Button>
        )
      },
    )
    CarouselPrevious.displayName = 'CarouselPrevious'
    
    const CarouselNext = React.forwardRef<
      HTMLButtonElement,
      React.ComponentProps<typeof Button> & { label?: string }
    >(
      (
        { className, variant = 'outline', size = 'icon', label = 'Next slide', children, ...props },
        ref,
      ) => {
        const { orientation, scrollNext, canScrollNext } = useCarousel()
        const isHorizontal = orientation !== 'vertical'
    
        return (
          <Button
            ref={ref}
            type="button"
            variant={variant}
            size={size}
            className={cn(
              'absolute z-10 size-8 shrink-0 rounded-full',
              'bg-background/80 border shadow-md backdrop-blur-sm',
              'hover:bg-accent hover:text-accent-foreground',
              'disabled:pointer-events-none disabled:opacity-50',
              isHorizontal
                ? 'top-1/2 -right-3 -translate-y-1/2'
                : '-bottom-3 left-1/2 -translate-x-1/2 rotate-90',
              className,
            )}
            disabled={!canScrollNext}
            aria-label={label}
            onClick={scrollNext}
            {...props}
          >
            {children ?? <ChevronRight className="size-4" aria-hidden="true" />}
          </Button>
        )
      },
    )
    CarouselNext.displayName = 'CarouselNext'
    
    const CarouselHeader = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement>
    >(({ className, ...props }, ref) => (
      <div
        ref={ref}
        data-uipkge=""
        data-slot="carousel-header"
        className={cn('flex items-center justify-between px-1 pb-2', className)}
        {...props}
      />
    ))
    CarouselHeader.displayName = 'CarouselHeader'
    
    const CarouselFooter = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement>
    >(({ className, ...props }, ref) => (
      <div
        ref={ref}
        data-uipkge=""
        data-slot="carousel-footer"
        className={cn('flex items-center justify-between px-1 pt-2', className)}
        {...props}
      />
    ))
    CarouselFooter.displayName = 'CarouselFooter'
    
    const CarouselIndicators = React.forwardRef<
      HTMLDivElement,
      React.HTMLAttributes<HTMLDivElement>
    >(({ className, ...props }, ref) => {
      const { api } = useCarousel()
      const [snaps, setSnaps] = React.useState<number[]>([])
      const [selectedIndex, setSelectedIndex] = React.useState(0)
    
      React.useEffect(() => {
        if (!api) return
        const onUpdate = () => {
          setSnaps(api.scrollSnapList())
          setSelectedIndex(api.selectedScrollSnap())
        }
        onUpdate()
        api.on('reInit', onUpdate)
        api.on('select', onUpdate)
        return () => {
          api?.off('reInit', onUpdate)
          api?.off('select', onUpdate)
        }
      }, [api])
    
      if (!api) return null
    
      return (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="carousel-indicators"
          className={cn('flex items-center justify-center gap-1.5 py-2', className)}
          role="tablist"
          aria-label="Carousel navigation"
          {...props}
        >
          {snaps.map((_, index) => (
            <button
              key={index}
              type="button"
              role="tab"
              aria-label={`Go to slide ${index + 1}`}
              aria-selected={index === selectedIndex}
              className={cn(
                'h-2 w-2 rounded-full transition-all duration-200',
                index === selectedIndex
                  ? 'bg-primary w-6'
                  : 'bg-muted-foreground/30 hover:bg-muted-foreground/50',
              )}
              onClick={() => api.scrollTo(index)}
            />
          ))}
        </div>
      )
    })
    CarouselIndicators.displayName = 'CarouselIndicators'
    
    export {
      type CarouselApi,
      Carousel,
      CarouselContent,
      CarouselItem,
      CarouselPrevious,
      CarouselNext,
      CarouselHeader,
      CarouselFooter,
      CarouselIndicators,
      useCarousel,
    }
  • components/ui/carousel/index.ts 0.2 kB
    export {
      type CarouselApi,
      type CarouselProps,
      Carousel,
      CarouselContent,
      CarouselItem,
      CarouselPrevious,
      CarouselNext,
      CarouselHeader,
      CarouselFooter,
      CarouselIndicators,
      useCarousel,
    } from './carousel'

Raw manifest: https://react.uipkge.dev/r/react/carousel.json