Carousel
A horizontal or vertical content slider with touch drag support, loop mode, and keyboard navigation. Built as a compound component with Carousel.Content, Carousel.Item, Carousel.Previous, and Carousel.Next.
Touch/DragKeyboard NavLoop ModeCompound API
Install
$
npx react-principles add carousel01
Live Demo
Explore all variants and interactive states in Storybook.
Open Storybookopen_in_newOptions
Slide 1
Slide 2
Slide 3
Slide 4
Slide 5
02
Code Snippet
src/ui/Carousel.tsx
import { Carousel } from "@/ui/Carousel"; <Carousel> <Carousel.Content> <Carousel.Item>Slide 1</Carousel.Item> <Carousel.Item>Slide 2</Carousel.Item> <Carousel.Item>Slide 3</Carousel.Item> </Carousel.Content> <Carousel.Previous /> <Carousel.Next /> </Carousel> // With loop mode <Carousel opts={{ loop: true }}> <Carousel.Content> <Carousel.Item>...</Carousel.Item> <Carousel.Item>...</Carousel.Item> </Carousel.Content> <Carousel.Previous /> <Carousel.Next /> </Carousel> // Vertical orientation <Carousel orientation="vertical" className="h-[400px]"> <Carousel.Content> <Carousel.Item>...</Carousel.Item> </Carousel.Content> <Carousel.Previous /> <Carousel.Next /> </Carousel> // Programmatic control via setApi <Carousel setApi={(api) => { window.carouselApi = api; }}> <Carousel.Content> <Carousel.Item>...</Carousel.Item> </Carousel.Content> </Carousel>
03
Copy-Paste (Single File)
Carousel.tsx
"use client"; import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode, } from "react"; import { cn } from "@/lib/utils"; // ─── Types ──────────────────────────────────────────────────────────────────── export type CarouselOrientation = "horizontal" | "vertical"; export interface CarouselOptions { loop?: boolean; align?: "start" | "center"; } export interface CarouselApi { scrollPrev: () => void; scrollNext: () => void; scrollTo: (index: number) => void; canScrollPrev: () => boolean; canScrollNext: () => boolean; getCurrentIndex: () => number; getItemCount: () => number; } export interface CarouselProps { orientation?: CarouselOrientation; opts?: CarouselOptions; setApi?: (api: CarouselApi) => void; children: ReactNode; className?: string; } export type CarouselContentProps = HTMLAttributes<HTMLDivElement>; export type CarouselItemProps = HTMLAttributes<HTMLDivElement>; export interface CarouselButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> { children?: ReactNode; } // ─── Context ────────────────────────────────────────────────────────────────── interface CarouselContextValue { currentIndex: number; orientation: CarouselOrientation; loop: boolean; itemCount: number; containerSize: number; registerItem: () => void; unregisterItem: () => void; scrollPrev: () => void; scrollNext: () => void; scrollTo: (index: number) => void; canScrollPrev: boolean; canScrollNext: boolean; api: CarouselApi; } const CarouselContext = createContext<CarouselContextValue | null>(null); function useCarouselContext(): CarouselContextValue { const ctx = useContext(CarouselContext); if (!ctx) throw new Error("Carousel sub-components must be used within <Carousel>"); return ctx; } const DRAG_THRESHOLD = 30; const RUBBER_BAND_FACTOR = 0.3; const TRANSITION_DURATION = 300; // ─── Carousel (root) ───────────────────────────────────────────────────────── export function Carousel({ orientation = "horizontal", opts, setApi, children, className, }: CarouselProps) { const loop = opts?.loop ?? false; const [currentIndex, setCurrentIndex] = useState(0); const [itemCount, setItemCount] = useState(0); const [containerSize, setContainerSize] = useState(0); const containerRef = useRef<HTMLDivElement>(null); const itemCountRef = useRef(0); const currentIndexRef = useRef(0); useEffect(() => { itemCountRef.current = itemCount; currentIndexRef.current = currentIndex; }, [itemCount, currentIndex]); const measure = useCallback(() => { const el = containerRef.current; if (!el) return; setContainerSize(orientation === "horizontal" ? el.offsetWidth : el.offsetHeight); }, [orientation]); useLayoutEffect(() => { measure(); }, [measure]); useEffect(() => { window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [measure]); const canScrollPrev = loop ? true : currentIndex > 0; const canScrollNext = loop ? true : currentIndex < itemCount - 1; const scrollTo = useCallback((index: number) => { const count = itemCountRef.current; if (count === 0) return; const clamped = loop ? ((index % count) + count) % count : Math.max(0, Math.min(index, count - 1)); setCurrentIndex(clamped); }, [loop]); const scrollPrev = useCallback(() => scrollTo(currentIndexRef.current - 1), [scrollTo]); const scrollNext = useCallback(() => scrollTo(currentIndexRef.current + 1), [scrollTo]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { const isHorizontal = orientation === "horizontal"; const prevKey = isHorizontal ? "ArrowLeft" : "ArrowUp"; const nextKey = isHorizontal ? "ArrowRight" : "ArrowDown"; if (e.key === prevKey) { e.preventDefault(); scrollPrev(); } else if (e.key === nextKey) { e.preventDefault(); scrollNext(); } else if (e.key === "Home") { e.preventDefault(); scrollTo(0); } else if (e.key === "End") { e.preventDefault(); scrollTo(itemCountRef.current - 1); } }, [orientation, scrollPrev, scrollNext, scrollTo]); const [api, setApiState] = useState<CarouselApi>(() => ({ scrollPrev, scrollNext, scrollTo, canScrollPrev: () => canScrollPrev, canScrollNext: () => canScrollNext, getCurrentIndex: () => currentIndexRef.current, getItemCount: () => itemCountRef.current, })); useEffect(() => { setApiState({ scrollPrev, scrollNext, scrollTo, canScrollPrev: () => canScrollPrev, canScrollNext: () => canScrollNext, getCurrentIndex: () => currentIndexRef.current, getItemCount: () => itemCountRef.current, }); }, [scrollPrev, scrollNext, scrollTo, canScrollPrev, canScrollNext]); useEffect(() => { if (setApi) setApi(api); }, [setApi, api]); const registerItem = useCallback(() => setItemCount((p) => p + 1), []); const unregisterItem = useCallback(() => setItemCount((p) => Math.max(0, p - 1)), []); return ( <CarouselContext.Provider value={{ currentIndex, orientation, loop, itemCount, containerSize, registerItem, unregisterItem, scrollPrev, scrollNext, scrollTo, canScrollPrev, canScrollNext, api, }} > <div ref={containerRef} role="region" aria-roledescription="carousel" aria-label="Carousel" tabIndex={0} className={cn("relative overflow-hidden", className)} onKeyDown={handleKeyDown}> {children} </div> </CarouselContext.Provider> ); } // ─── CarouselContent (track) ───────────────────────────────────────────────── Carousel.Content = function CarouselContent({ children, className, ...props }: CarouselContentProps) { const { currentIndex, orientation, loop, itemCount, containerSize, scrollPrev, scrollNext } = useCarouselContext(); const trackRef = useRef<HTMLDivElement>(null); const isDraggingRef = useRef(false); const startClientRef = useRef(0); const dragOffsetRef = useRef(0); const pointerIdRef = useRef<number | null>(null); const startTimeRef = useRef(0); const startTranslateRef = useRef(0); const triggerPrev = useCallback(() => scrollPrev(), [scrollPrev]); const triggerNext = useCallback(() => scrollNext(), [scrollNext]); const [snapTranslate, setSnapTranslate] = useState(0); const [isDragging, setIsDragging] = useState(false); useEffect(() => { setSnapTranslate(-currentIndex * containerSize); }, [currentIndex, containerSize]); const translateValue = isDragging ? startTranslateRef.current + dragOffsetRef.current : snapTranslate; const axis = orientation === "horizontal" ? "X" : "Y"; const handlePointerDown = useCallback((e: React.PointerEvent<HTMLDivElement>) => { if (e.pointerType === "mouse" && e.button !== 0) return; e.preventDefault(); const track = trackRef.current; if (!track) return; track.setPointerCapture(e.pointerId); pointerIdRef.current = e.pointerId; isDraggingRef.current = true; setIsDragging(true); startTimeRef.current = Date.now(); startClientRef.current = orientation === "horizontal" ? e.clientX : e.clientY; dragOffsetRef.current = 0; startTranslateRef.current = snapTranslate; }, [orientation, snapTranslate]); const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => { if (!isDraggingRef.current) return; e.preventDefault(); const clientPos = orientation === "horizontal" ? e.clientX : e.clientY; const delta = clientPos - startClientRef.current; if (!loop && itemCount > 0) { const atStart = currentIndex <= 0 && delta > 0; const atEnd = currentIndex >= itemCount - 1 && delta < 0; dragOffsetRef.current = (atStart || atEnd) ? delta * RUBBER_BAND_FACTOR : delta; } else { dragOffsetRef.current = delta; } }, [orientation, loop, currentIndex, itemCount]); const handlePointerUp = useCallback((_e: React.PointerEvent<HTMLDivElement>) => { if (!isDraggingRef.current) return; isDraggingRef.current = false; setIsDragging(false); const track = trackRef.current; if (track && pointerIdRef.current !== null) { try { track.releasePointerCapture(pointerIdRef.current); } catch { /* already released */ } } pointerIdRef.current = null; const elapsed = Date.now() - startTimeRef.current; const velocity = Math.abs(dragOffsetRef.current) / Math.max(elapsed, 1); if (Math.abs(dragOffsetRef.current) > DRAG_THRESHOLD || (velocity > 0.3 && elapsed < 500)) { if (dragOffsetRef.current < 0) triggerNext(); else if (dragOffsetRef.current > 0) triggerPrev(); } dragOffsetRef.current = 0; }, [triggerPrev, triggerNext]); const handlePointerCancel = useCallback(() => { isDraggingRef.current = false; setIsDragging(false); pointerIdRef.current = null; dragOffsetRef.current = 0; }, []); return ( <div ref={trackRef} className={cn("flex", orientation === "horizontal" ? "flex-row" : "flex-col", isDragging ? "select-none" : "", className)} style={{ transform: `translate${axis}(${translateValue}px)`, transition: isDragging ? "none" : `transform ${TRANSITION_DURATION}ms ease-out`, touchAction: "none", }} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} onPointerCancel={handlePointerCancel} {...props}> {children} </div> ); }; // ─── CarouselItem ──────────────────────────────────────────────────────────── Carousel.Item = function CarouselItem({ children, className, ...props }: CarouselItemProps) { const { registerItem, unregisterItem } = useCarouselContext(); useEffect(() => { registerItem(); return () => unregisterItem(); }, [registerItem, unregisterItem]); return ( <div role="group" aria-roledescription="slide" className={cn("flex-shrink-0", className)} style={{ flexBasis: "100%", minHeight: 0, minWidth: 0 }} {...props}> {children} </div> ); }; // ─── CarouselPrevious ──────────────────────────────────────────────────────── Carousel.Previous = function CarouselPrevious({ children, className, disabled: extDisabled, ...props }: CarouselButtonProps) { const { scrollPrev, canScrollPrev, orientation } = useCarouselContext(); const isHorizontal = orientation === "horizontal"; return ( <button type="button" onClick={scrollPrev} disabled={extDisabled ?? !canScrollPrev} aria-label="Previous slide" className={cn( "absolute z-10 flex h-8 w-8 items-center justify-center rounded-full", "bg-white/80 text-slate-700 shadow-xs backdrop-blur-sm transition-all", "hover:bg-white dark:bg-[#0d1117]/80 dark:text-slate-300 dark:hover:bg-[#0d1117]", "disabled:pointer-events-none disabled:opacity-50", isHorizontal ? "left-2 top-1/2 -translate-y-1/2" : "top-2 left-1/2 -translate-x-1/2", className, )} {...props}> {children ?? <span className="material-symbols-outlined text-[20px]">{isHorizontal ? "chevron_left" : "expand_less"}</span>} </button> ); }; // ─── CarouselNext ──────────────────────────────────────────────────────────── Carousel.Next = function CarouselNext({ children, className, disabled: extDisabled, ...props }: CarouselButtonProps) { const { scrollNext, canScrollNext, orientation } = useCarouselContext(); const isHorizontal = orientation === "horizontal"; return ( <button type="button" onClick={scrollNext} disabled={extDisabled ?? !canScrollNext} aria-label="Next slide" className={cn( "absolute z-10 flex h-8 w-8 items-center justify-center rounded-full", "bg-white/80 text-slate-700 shadow-xs backdrop-blur-sm transition-all", "hover:bg-white dark:bg-[#0d1117]/80 dark:text-slate-300 dark:hover:bg-[#0d1117]", "disabled:pointer-events-none disabled:opacity-50", isHorizontal ? "right-2 top-1/2 -translate-y-1/2" : "bottom-2 left-1/2 -translate-x-1/2", className, )} {...props}> {children ?? <span className="material-symbols-outlined text-[20px]">{isHorizontal ? "chevron_right" : "expand_more"}</span>} </button> ); };
04
Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | Scroll direction of the carousel. |
opts.loop | boolean | false | Whether to wrap from last to first item. |
opts.align | "start" | "center" | "start" | How items align within the viewport. |
setApi | (api: CarouselApi) => void | — | Callback to access the carousel API for programmatic control. |
className | string | — | Additional classes merged into the root container. |