Docs/Components/Carousel

Carousel

A slideshow component for cycling through elements with navigation controls and touch/drag support.

Slide 1
Slide 2
Slide 3
Slide 4

Installation

Install the component Carousel in your project using the CLI.

Carousel.tsx

pnpm dlx behsseui@latest add Carousel

Install 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"
2
3import { 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"
7
8// Context for sharing carousel state
9interface CarouselContextValue {
10 currentIndex: number
11 totalSlides: number
12 slidesPerView: number
13 direction: "horizontal" | "vertical"
14 goToSlide: (index: number) => void
15 goToPrevious: () => void
16 goToNext: () => void
17 canGoNext: boolean
18 canGoPrevious: boolean
19}
20
21const CarouselContext = createContext<CarouselContextValue | null>(null)
22
23function useCarousel() {
24 const context = useContext(CarouselContext)
25 if (!context) {
26 throw new Error("useCarousel must be used within a Carousel")
27 }
28 return context
29}
30
31// Types
32interface CarouselProps {
33 children: React.ReactNode
34 slidesPerView?: number | "auto"
35 spaceBetween?: number
36 autoplay?: boolean
37 autoplayDelay?: number
38 loop?: boolean
39 direction?: "horizontal" | "vertical"
40 className?: string
41}
42
43interface CarouselContentProps {
44 children: React.ReactNode
45 className?: string
46}
47
48interface CarouselItemProps {
49 children: React.ReactNode
50 className?: string
51}
52
53interface CarouselPreviousProps {
54 className?: string
55}
56
57interface CarouselNextProps {
58 className?: string
59}
60
61interface CarouselDotsProps {
62 className?: string
63}
64
65// Main Carousel component
66function 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)
83
84 const isVertical = direction === "vertical"
85 const effectiveSlidesPerView = typeof slidesPerView === "number" ? slidesPerView : 1
86 const maxIndex = Math.max(0, totalSlides - effectiveSlidesPerView)
87
88 const canGoNext = loop || currentIndex < maxIndex
89 const canGoPrevious = loop || currentIndex > 0
90
91 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])
104
105 const goToNext = useCallback(() => {
106 goToSlide(currentIndex + 1)
107 }, [currentIndex, goToSlide])
108
109 const goToPrevious = useCallback(() => {
110 goToSlide(currentIndex - 1)
111 }, [currentIndex, goToSlide])
112
113 // Autoplay
114 useEffect(() => {
115 if (autoplay && !isDragging) {
116 autoplayRef.current = setInterval(() => {
117 goToNext()
118 }, autoplayDelay)
119 }
120
121 return () => {
122 if (autoplayRef.current) {
123 clearInterval(autoplayRef.current)
124 }
125 }
126 }, [autoplay, autoplayDelay, goToNext, isDragging])
127
128 // Mouse/Touch drag handling
129 const handleDragStart = useCallback((pos: number) => {
130 setIsDragging(true)
131 setStartPos(pos)
132 setTranslatePos(0)
133 }, [])
134
135 const handleDragMove = useCallback((pos: number) => {
136 if (!isDragging) return
137 const diff = pos - startPos
138 setTranslatePos(diff)
139 }, [isDragging, startPos])
140
141 const handleDragEnd = useCallback(() => {
142 if (!isDragging) return
143 setIsDragging(false)
144
145 const threshold = 50
146 if (translatePos > threshold && canGoPrevious) {
147 goToPrevious()
148 } else if (translatePos < -threshold && canGoNext) {
149 goToNext()
150 }
151
152 setTranslatePos(0)
153 }, [isDragging, translatePos, canGoPrevious, canGoNext, goToPrevious, goToNext])
154
155 // Mouse events
156 const handleMouseDown = (e: React.MouseEvent) => {
157 e.preventDefault()
158 handleDragStart(isVertical ? e.clientY : e.clientX)
159 }
160
161 const handleMouseMove = (e: React.MouseEvent) => {
162 handleDragMove(isVertical ? e.clientY : e.clientX)
163 }
164
165 const handleMouseUp = () => {
166 handleDragEnd()
167 }
168
169 const handleMouseLeave = () => {
170 if (isDragging) {
171 handleDragEnd()
172 }
173 }
174
175 // Touch events
176 const handleTouchStart = (e: React.TouchEvent) => {
177 handleDragStart(isVertical ? e.touches[0].clientY : e.touches[0].clientX)
178 }
179
180 const handleTouchMove = (e: React.TouchEvent) => {
181 handleDragMove(isVertical ? e.touches[0].clientY : e.touches[0].clientX)
182 }
183
184 const handleTouchEnd = () => {
185 handleDragEnd()
186 }
187
188 const contextValue: CarouselContextValue = {
189 currentIndex,
190 totalSlides,
191 slidesPerView: effectiveSlidesPerView,
192 direction,
193 goToSlide,
194 goToPrevious,
195 goToNext,
196 canGoNext,
197 canGoPrevious,
198 }
199
200 // Separate children into content+buttons vs other elements (like dots)
201 const contentAndButtons: React.ReactNode[] = []
202 const otherElements: React.ReactNode[] = []
203
204 Children.forEach(children, (child) => {
205 if (!child || typeof child !== "object" || !("type" in child)) {
206 otherElements.push(child)
207 return
208 }
209
210 if (child.type === CarouselContent || child.type === CarouselPrevious || child.type === CarouselNext) {
211 contentAndButtons.push(child)
212 } else {
213 otherElements.push(child)
214 }
215 })
216
217 return (
218 <CarouselContext.Provider value={contextValue}>
219 <div
220 ref={containerRef}
221 className={cn(
222 "w-full",
223 isVertical && "h-full",
224 className
225 )}
226 >
227 {/* Wrapper for content and navigation buttons - buttons are positioned relative to this */}
228 <div
229 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 child
243
244 if (child.type === CarouselContent) {
245 const contentProps = child.props as CarouselContentProps
246 return (
247 <CarouselContentInternal
248 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>
263
264 {/* Other elements like dots are rendered outside the relative wrapper */}
265 {otherElements}
266 </div>
267 </CarouselContext.Provider>
268 )
269}
270
271// Internal CarouselContent with state access
272interface CarouselContentInternalProps extends CarouselContentProps {
273 currentIndex: number
274 slidesPerView: number
275 spaceBetween: number
276 translatePos: number
277 isDragging: boolean
278 direction: "horizontal" | "vertical"
279 setTotalSlides: (count: number) => void
280}
281
282function 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)
296
297 const isVertical = direction === "vertical"
298
299 useEffect(() => {
300 setTotalSlides(childrenArray.length)
301 }, [childrenArray.length, setTotalSlides])
302
303 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 height
311 const timeout = setTimeout(updateSize, 50)
312 window.addEventListener("resize", updateSize)
313 return () => {
314 window.removeEventListener("resize", updateSize)
315 clearTimeout(timeout)
316 }
317 }, [isVertical])
318
319 // Calculate slide size in pixels
320 const totalGapSize = spaceBetween * (slidesPerView - 1)
321 const slideSizePx = (containerSize - totalGapSize) / slidesPerView
322
323 // Calculate offset in pixels: each step moves by (slideSize + gap)
324 const offsetPx = currentIndex * (slideSizePx + spaceBetween)
325
326 const transform = isVertical
327 ? `translateY(${-offsetPx + translatePos}px)`
328 : `translateX(${-offsetPx + translatePos}px)`
329
330 return (
331 <div
332 ref={containerRef}
333 className={cn(
334 "overflow-hidden",
335 isVertical && "h-full",
336 className
337 )}
338 >
339 <div
340 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 <div
352 key={index}
353 className={cn(
354 "shrink-0",
355 isVertical && "h-full"
356 )}
357 style={
358 isVertical
359 ? { 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}
370
371// Public CarouselContent (just a marker component)
372function CarouselContent({ children, className }: CarouselContentProps) {
373 return <div className={className}>{children}</div>
374}
375
376// CarouselItem
377function CarouselItem({ children, className }: CarouselItemProps) {
378 return (
379 <div className={cn("min-w-0 h-full", className)}>
380 {children}
381 </div>
382 )
383}
384
385// CarouselPrevious button
386function CarouselPrevious({ className }: CarouselPreviousProps) {
387 const { goToPrevious, canGoPrevious, direction } = useCarousel()
388 const isVertical = direction === "vertical"
389
390 return (
391 <button
392 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 isVertical
406 ? "top-2 left-1/2 -translate-x-1/2"
407 : "left-2 top-1/2 -translate-y-1/2",
408 className
409 )}
410 aria-label="Previous slide"
411 >
412 <ChevronLeft className={cn("h-4 w-4", isVertical && "rotate-90")} />
413 </button>
414 )
415}
416
417// CarouselNext button
418function CarouselNext({ className }: CarouselNextProps) {
419 const { goToNext, canGoNext, direction } = useCarousel()
420 const isVertical = direction === "vertical"
421
422 return (
423 <button
424 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 isVertical
438 ? "bottom-2 left-1/2 -translate-x-1/2"
439 : "right-2 top-1/2 -translate-y-1/2",
440 className
441 )}
442 aria-label="Next slide"
443 >
444 <ChevronRight className={cn("h-4 w-4", isVertical && "rotate-90")} />
445 </button>
446 )
447}
448
449// CarouselDots indicator
450function 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"
454
455 if (dotsCount <= 1) return null
456
457 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 className
462 )}>
463 {Array.from({ length: dotsCount }).map((_, index) => (
464 <button
465 key={index}
466 type="button"
467 onClick={() => goToSlide(index)}
468 className={cn(
469 "rounded-full transition-all",
470 isVertical
471 ? 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}
480
481export {
482 Carousel,
483 CarouselContent,
484 CarouselItem,
485 CarouselPrevious,
486 CarouselNext,
487 CarouselDots,
488}
489

Usages

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.

Slide 1
Slide 2
Slide 3
Slide 4

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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5

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.

Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
Slide 6

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.

Slide 1
Slide 2
Slide 3
Slide 4

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.

Slide 1
Slide 2
Slide 3
Slide 4

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.

Slide 1
Slide 2
Slide 3
Slide 4

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.

Slide 1
Slide 2
Slide 3
Slide 4

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>