Carousel
A slideshow component for cycling through elements with navigation controls and touch/drag support.
Installation
Install the component Carousel in your project using the CLI.
Carousel.tsx
pnpm dlx behsseui@latest add CarouselInstall the component manually.
Create a ui folder at the root of the project, then a component folder inside it, and finally a Carousel.tsx file in that folder.
Copy and paste the following code into your project.
ui/components/Carousel.tsx
1"use client"23import { useState, useEffect, useCallback, useRef, Children, createContext, useContext, isValidElement, cloneElement } from "react"4import { cn } from "@/lib/utils"5import ChevronLeft from "@/ui/icons/ChevronLeft"6import ChevronRight from "@/ui/icons/ChevronRight"78// Context for sharing carousel state9interface CarouselContextValue {10 currentIndex: number11 totalSlides: number12 slidesPerView: number13 direction: "horizontal" | "vertical"14 goToSlide: (index: number) => void15 goToPrevious: () => void16 goToNext: () => void17 canGoNext: boolean18 canGoPrevious: boolean19}2021const CarouselContext = createContext<CarouselContextValue | null>(null)2223function useCarousel() {24 const context = useContext(CarouselContext)25 if (!context) {26 throw new Error("useCarousel must be used within a Carousel")27 }28 return context29}3031// Types32interface CarouselProps {33 children: React.ReactNode34 slidesPerView?: number | "auto"35 spaceBetween?: number36 autoplay?: boolean37 autoplayDelay?: number38 loop?: boolean39 direction?: "horizontal" | "vertical"40 className?: string41}4243interface CarouselContentProps {44 children: React.ReactNode45 className?: string46}4748interface CarouselItemProps {49 children: React.ReactNode50 className?: string51}5253interface CarouselPreviousProps {54 className?: string55}5657interface CarouselNextProps {58 className?: string59}6061interface CarouselDotsProps {62 className?: string63}6465// Main Carousel component66function Carousel({67 children,68 slidesPerView = 1,69 spaceBetween = 16,70 autoplay = false,71 autoplayDelay = 3000,72 loop = false,73 direction = "horizontal",74 className,75}: CarouselProps) {76 const [currentIndex, setCurrentIndex] = useState(0)77 const [totalSlides, setTotalSlides] = useState(0)78 const [isDragging, setIsDragging] = useState(false)79 const [startPos, setStartPos] = useState(0)80 const [translatePos, setTranslatePos] = useState(0)81 const containerRef = useRef<HTMLDivElement>(null)82 const autoplayRef = useRef<NodeJS.Timeout | null>(null)8384 const isVertical = direction === "vertical"85 const effectiveSlidesPerView = typeof slidesPerView === "number" ? slidesPerView : 186 const maxIndex = Math.max(0, totalSlides - effectiveSlidesPerView)8788 const canGoNext = loop || currentIndex < maxIndex89 const canGoPrevious = loop || currentIndex > 09091 const goToSlide = useCallback((index: number) => {92 if (loop) {93 if (index < 0) {94 setCurrentIndex(maxIndex)95 } else if (index > maxIndex) {96 setCurrentIndex(0)97 } else {98 setCurrentIndex(index)99 }100 } else {101 setCurrentIndex(Math.max(0, Math.min(index, maxIndex)))102 }103 }, [loop, maxIndex])104105 const goToNext = useCallback(() => {106 goToSlide(currentIndex + 1)107 }, [currentIndex, goToSlide])108109 const goToPrevious = useCallback(() => {110 goToSlide(currentIndex - 1)111 }, [currentIndex, goToSlide])112113 // Autoplay114 useEffect(() => {115 if (autoplay && !isDragging) {116 autoplayRef.current = setInterval(() => {117 goToNext()118 }, autoplayDelay)119 }120121 return () => {122 if (autoplayRef.current) {123 clearInterval(autoplayRef.current)124 }125 }126 }, [autoplay, autoplayDelay, goToNext, isDragging])127128 // Mouse/Touch drag handling129 const handleDragStart = useCallback((pos: number) => {130 setIsDragging(true)131 setStartPos(pos)132 setTranslatePos(0)133 }, [])134135 const handleDragMove = useCallback((pos: number) => {136 if (!isDragging) return137 const diff = pos - startPos138 setTranslatePos(diff)139 }, [isDragging, startPos])140141 const handleDragEnd = useCallback(() => {142 if (!isDragging) return143 setIsDragging(false)144145 const threshold = 50146 if (translatePos > threshold && canGoPrevious) {147 goToPrevious()148 } else if (translatePos < -threshold && canGoNext) {149 goToNext()150 }151152 setTranslatePos(0)153 }, [isDragging, translatePos, canGoPrevious, canGoNext, goToPrevious, goToNext])154155 // Mouse events156 const handleMouseDown = (e: React.MouseEvent) => {157 e.preventDefault()158 handleDragStart(isVertical ? e.clientY : e.clientX)159 }160161 const handleMouseMove = (e: React.MouseEvent) => {162 handleDragMove(isVertical ? e.clientY : e.clientX)163 }164165 const handleMouseUp = () => {166 handleDragEnd()167 }168169 const handleMouseLeave = () => {170 if (isDragging) {171 handleDragEnd()172 }173 }174175 // Touch events176 const handleTouchStart = (e: React.TouchEvent) => {177 handleDragStart(isVertical ? e.touches[0].clientY : e.touches[0].clientX)178 }179180 const handleTouchMove = (e: React.TouchEvent) => {181 handleDragMove(isVertical ? e.touches[0].clientY : e.touches[0].clientX)182 }183184 const handleTouchEnd = () => {185 handleDragEnd()186 }187188 const contextValue: CarouselContextValue = {189 currentIndex,190 totalSlides,191 slidesPerView: effectiveSlidesPerView,192 direction,193 goToSlide,194 goToPrevious,195 goToNext,196 canGoNext,197 canGoPrevious,198 }199200 // Separate children into content+buttons vs other elements (like dots)201 const contentAndButtons: React.ReactNode[] = []202 const otherElements: React.ReactNode[] = []203204 Children.forEach(children, (child) => {205 if (!child || typeof child !== "object" || !("type" in child)) {206 otherElements.push(child)207 return208 }209210 if (child.type === CarouselContent || child.type === CarouselPrevious || child.type === CarouselNext) {211 contentAndButtons.push(child)212 } else {213 otherElements.push(child)214 }215 })216217 return (218 <CarouselContext.Provider value={contextValue}>219 <div220 ref={containerRef}221 className={cn(222 "w-full",223 isVertical && "h-full",224 className225 )}226 >227 {/* Wrapper for content and navigation buttons - buttons are positioned relative to this */}228 <div229 className={cn(230 "relative",231 isVertical && "h-full"232 )}233 onMouseDown={handleMouseDown}234 onMouseMove={handleMouseMove}235 onMouseUp={handleMouseUp}236 onMouseLeave={handleMouseLeave}237 onTouchStart={handleTouchStart}238 onTouchMove={handleTouchMove}239 onTouchEnd={handleTouchEnd}240 >241 {contentAndButtons.map((child, index) => {242 if (!isValidElement(child)) return child243244 if (child.type === CarouselContent) {245 const contentProps = child.props as CarouselContentProps246 return (247 <CarouselContentInternal248 key={index}249 {...contentProps}250 currentIndex={currentIndex}251 slidesPerView={effectiveSlidesPerView}252 spaceBetween={spaceBetween}253 translatePos={translatePos}254 isDragging={isDragging}255 direction={direction}256 setTotalSlides={setTotalSlides}257 />258 )259 }260 return cloneElement(child, { key: index })261 })}262 </div>263264 {/* Other elements like dots are rendered outside the relative wrapper */}265 {otherElements}266 </div>267 </CarouselContext.Provider>268 )269}270271// Internal CarouselContent with state access272interface CarouselContentInternalProps extends CarouselContentProps {273 currentIndex: number274 slidesPerView: number275 spaceBetween: number276 translatePos: number277 isDragging: boolean278 direction: "horizontal" | "vertical"279 setTotalSlides: (count: number) => void280}281282function CarouselContentInternal({283 children,284 className,285 currentIndex,286 slidesPerView,287 spaceBetween,288 translatePos,289 isDragging,290 direction,291 setTotalSlides,292}: CarouselContentInternalProps) {293 const childrenArray = Children.toArray(children)294 const containerRef = useRef<HTMLDivElement>(null)295 const [containerSize, setContainerSize] = useState(0)296297 const isVertical = direction === "vertical"298299 useEffect(() => {300 setTotalSlides(childrenArray.length)301 }, [childrenArray.length, setTotalSlides])302303 useEffect(() => {304 const updateSize = () => {305 if (containerRef.current) {306 setContainerSize(isVertical ? containerRef.current.offsetHeight : containerRef.current.offsetWidth)307 }308 }309 updateSize()310 // Small delay to ensure parent has rendered with correct height311 const timeout = setTimeout(updateSize, 50)312 window.addEventListener("resize", updateSize)313 return () => {314 window.removeEventListener("resize", updateSize)315 clearTimeout(timeout)316 }317 }, [isVertical])318319 // Calculate slide size in pixels320 const totalGapSize = spaceBetween * (slidesPerView - 1)321 const slideSizePx = (containerSize - totalGapSize) / slidesPerView322323 // Calculate offset in pixels: each step moves by (slideSize + gap)324 const offsetPx = currentIndex * (slideSizePx + spaceBetween)325326 const transform = isVertical327 ? `translateY(${-offsetPx + translatePos}px)`328 : `translateX(${-offsetPx + translatePos}px)`329330 return (331 <div332 ref={containerRef}333 className={cn(334 "overflow-hidden",335 isVertical && "h-full",336 className337 )}338 >339 <div340 className={cn(341 "flex h-full",342 isVertical && "flex-col",343 !isDragging && "transition-transform duration-300 ease-out"344 )}345 style={{346 transform,347 gap: `${spaceBetween}px`,348 }}349 >350 {childrenArray.map((child, index) => (351 <div352 key={index}353 className={cn(354 "shrink-0",355 isVertical && "h-full"356 )}357 style={358 isVertical359 ? { height: slideSizePx > 0 ? `${slideSizePx}px` : '100%' }360 : { width: slideSizePx > 0 ? `${slideSizePx}px` : `calc((100% - ${totalGapSize}px) / ${slidesPerView})` }361 }362 >363 {child}364 </div>365 ))}366 </div>367 </div>368 )369}370371// Public CarouselContent (just a marker component)372function CarouselContent({ children, className }: CarouselContentProps) {373 return <div className={className}>{children}</div>374}375376// CarouselItem377function CarouselItem({ children, className }: CarouselItemProps) {378 return (379 <div className={cn("min-w-0 h-full", className)}>380 {children}381 </div>382 )383}384385// CarouselPrevious button386function CarouselPrevious({ className }: CarouselPreviousProps) {387 const { goToPrevious, canGoPrevious, direction } = useCarousel()388 const isVertical = direction === "vertical"389390 return (391 <button392 type="button"393 onClick={(e) => {394 e.stopPropagation()395 goToPrevious()396 }}397 disabled={!canGoPrevious}398 className={cn(399 "absolute z-10",400 "h-8 w-8 rounded-full",401 "bg-background/80 backdrop-blur-sm border border-border",402 "flex items-center justify-center",403 "hover:bg-accent transition-colors",404 "disabled:opacity-50 disabled:cursor-not-allowed",405 isVertical406 ? "top-2 left-1/2 -translate-x-1/2"407 : "left-2 top-1/2 -translate-y-1/2",408 className409 )}410 aria-label="Previous slide"411 >412 <ChevronLeft className={cn("h-4 w-4", isVertical && "rotate-90")} />413 </button>414 )415}416417// CarouselNext button418function CarouselNext({ className }: CarouselNextProps) {419 const { goToNext, canGoNext, direction } = useCarousel()420 const isVertical = direction === "vertical"421422 return (423 <button424 type="button"425 onClick={(e) => {426 e.stopPropagation()427 goToNext()428 }}429 disabled={!canGoNext}430 className={cn(431 "absolute z-10",432 "h-8 w-8 rounded-full",433 "bg-background/80 backdrop-blur-sm border border-border",434 "flex items-center justify-center",435 "hover:bg-accent transition-colors",436 "disabled:opacity-50 disabled:cursor-not-allowed",437 isVertical438 ? "bottom-2 left-1/2 -translate-x-1/2"439 : "right-2 top-1/2 -translate-y-1/2",440 className441 )}442 aria-label="Next slide"443 >444 <ChevronRight className={cn("h-4 w-4", isVertical && "rotate-90")} />445 </button>446 )447}448449// CarouselDots indicator450function CarouselDots({ className }: CarouselDotsProps) {451 const { currentIndex, totalSlides, slidesPerView, direction, goToSlide } = useCarousel()452 const dotsCount = Math.max(0, totalSlides - slidesPerView + 1)453 const isVertical = direction === "vertical"454455 if (dotsCount <= 1) return null456457 return (458 <div className={cn(459 "flex justify-center gap-2",460 isVertical ? "flex-col absolute right-2 top-1/2 -translate-y-1/2" : "mt-4",461 className462 )}>463 {Array.from({ length: dotsCount }).map((_, index) => (464 <button465 key={index}466 type="button"467 onClick={() => goToSlide(index)}468 className={cn(469 "rounded-full transition-all",470 isVertical471 ? cn("w-2 h-2", currentIndex === index ? "bg-primary h-4" : "bg-muted-foreground/30 hover:bg-muted-foreground/50")472 : cn("h-2 w-2", currentIndex === index ? "bg-primary w-4" : "bg-muted-foreground/30 hover:bg-muted-foreground/50")473 )}474 aria-label={`Go to slide ${index + 1}`}475 />476 ))}477 </div>478 )479}480481export {482 Carousel,483 CarouselContent,484 CarouselItem,485 CarouselPrevious,486 CarouselNext,487 CarouselDots,488}489Usages
Different variants and use cases for the Carousel component.
Default
A basic carousel showing one slide at a time with arrow navigation and drag support.
Default.tsx
<Carousel className="w-full max-w-md">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Two Per View
Show two slides at a time.
Two Per View.tsx
<Carousel slidesPerView={2} spaceBetween={16} className="w-full max-w-lg">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 4</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Three Per View
Show three slides at a time.
Three Per View.tsx
<Carousel slidesPerView={3} spaceBetween={12} className="w-full max-w-2xl">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 4</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 5</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Autoplay
Automatically cycle through slides. Combine with loop for infinite scrolling.
Autoplay.tsx
<Carousel autoplay autoplayDelay={3000} loop className="w-full max-w-md">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>Loop
Enable infinite looping through slides.
Loop.tsx
<Carousel loop className="w-full max-w-md">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>With Dots
Add dot indicators for navigation.
With Dots.tsx
<Carousel className="w-full max-w-md">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg">Slide 3</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
<CarouselDots />
</Carousel>Vertical
A vertical carousel that scrolls up and down. Set a fixed height on the container.
Vertical.tsx
<Carousel direction="vertical" className="w-full max-w-md h-[300px]">
<CarouselContent>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg h-full flex items-center justify-center">Slide 1</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg h-full flex items-center justify-center">Slide 2</div>
</CarouselItem>
<CarouselItem>
<div className="p-6 bg-muted rounded-lg h-full flex items-center justify-center">Slide 3</div>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>