Layout

Carousel

A snap-scroll carousel on native scroll physics with end-aware prev/next buttons, a sliding active-dot pill, desktop pointer drag, and no autoplay.

Install

npx shadcn@latest add @paragon/carousel

Also installs: button

carousel.tsx

"use client";

import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { Button } from "@/registry/paragon/ui/button";
import { cn } from "@/lib/utils";

interface CarouselContextValue {
  viewportRef: React.RefObject<HTMLUListElement | null>;
  index: number;
  count: number;
  canPrev: boolean;
  canNext: boolean;
  scrollToIndex: (index: number) => void;
}

const CarouselContext = React.createContext<CarouselContextValue | null>(null);

function useCarousel(consumer: string) {
  const context = React.useContext(CarouselContext);
  if (!context) {
    throw new Error(`<${consumer}> must be used within <Carousel>`);
  }
  return context;
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

export interface CarouselProps extends React.ComponentProps<"div"> {}

/**
 * A snap-scroll carousel on native CSS scroll physics — no autoplay, no
 * scroll hijacking. Position (index, ends) is derived from the real
 * scroll offset, so trackpads, touch, keyboard scrolling, buttons, and
 * desktop pointer-drag all stay in sync. Programmatic scrolls fall back
 * to instant jumps under reduced motion.
 */
export function Carousel({ className, children, ...props }: CarouselProps) {
  const viewportRef = React.useRef<HTMLUListElement>(null);
  const [index, setIndex] = React.useState(0);
  const [count, setCount] = React.useState(0);
  const [canPrev, setCanPrev] = React.useState(false);
  const [canNext, setCanNext] = React.useState(false);

  React.useEffect(() => {
    const el = viewportRef.current;
    if (!el) return;
    const update = () => {
      const slides = Array.from(el.children).filter(
        (child) => child instanceof HTMLElement && child.dataset.slot === "carousel-item",
      ) as HTMLElement[];
      setCount(slides.length);
      const x = el.scrollLeft;
      let nearest = 0;
      let best = Infinity;
      slides.forEach((slide, i) => {
        const distance = Math.abs(slide.offsetLeft - x);
        if (distance < best) {
          best = distance;
          nearest = i;
        }
      });
      setIndex(nearest);
      setCanPrev(x > 2);
      setCanNext(x < el.scrollWidth - el.clientWidth - 2);
    };
    update();
    el.addEventListener("scroll", update, { passive: true });
    const observer = new ResizeObserver(update);
    observer.observe(el);
    return () => {
      el.removeEventListener("scroll", update);
      observer.disconnect();
    };
  }, []);

  const scrollToIndex = React.useCallback((i: number) => {
    const el = viewportRef.current;
    if (!el) return;
    const slide = el.children[i] as HTMLElement | undefined;
    if (!slide) return;
    el.scrollTo({
      left: slide.offsetLeft,
      behavior: prefersReducedMotion() ? "auto" : "smooth",
    });
  }, []);

  const context = React.useMemo(
    () => ({ viewportRef, index, count, canPrev, canNext, scrollToIndex }),
    [index, count, canPrev, canNext, scrollToIndex],
  );

  return (
    <CarouselContext.Provider value={context}>
      <div
        data-slot="carousel"
        role="region"
        aria-roledescription="carousel"
        className={cn("relative flex w-full flex-col gap-4", className)}
        {...props}
      >
        {children}
      </div>
    </CarouselContext.Provider>
  );
}

export interface CarouselContentProps extends React.ComponentProps<"ul"> {}

interface DragState {
  pointerId: number;
  startX: number;
  startLeft: number;
  moved: boolean;
}

export function CarouselContent({
  className,
  onPointerDown,
  onClickCapture,
  ...props
}: CarouselContentProps) {
  const { viewportRef } = useCarousel("CarouselContent");
  const drag = React.useRef<DragState | null>(null);
  const suppressClick = React.useRef(false);

  /** Re-enable snapping only after the settle scroll finishes. */
  const restoreSnap = (el: HTMLUListElement) => {
    let done = false;
    const restore = () => {
      if (done) return;
      done = true;
      el.style.scrollSnapType = "";
      el.removeEventListener("scrollend", restore);
    };
    el.addEventListener("scrollend", restore);
    window.setTimeout(restore, 600);
  };

  const endDrag = (event: React.PointerEvent<HTMLUListElement>) => {
    const state = drag.current;
    const el = viewportRef.current;
    if (!state || !el || event.pointerId !== state.pointerId) return;
    drag.current = null;
    delete el.dataset.dragging;
    if (!state.moved) return;
    if (el.hasPointerCapture(event.pointerId)) {
      el.releasePointerCapture(event.pointerId);
    }
    suppressClick.current = true;
    window.setTimeout(() => {
      suppressClick.current = false;
    }, 0);
    // Settle onto the nearest snap point, then hand back to CSS snap.
    const slides = Array.from(el.children) as HTMLElement[];
    let target = el.scrollLeft;
    let best = Infinity;
    for (const slide of slides) {
      const distance = Math.abs(slide.offsetLeft - el.scrollLeft);
      if (distance < best) {
        best = distance;
        target = slide.offsetLeft;
      }
    }
    if (prefersReducedMotion()) {
      el.scrollTo({ left: target });
      el.style.scrollSnapType = "";
    } else {
      restoreSnap(el);
      el.scrollTo({ left: target, behavior: "smooth" });
    }
  };

  return (
    <ul
      ref={viewportRef}
      data-slot="carousel-content"
      className={cn(
        "relative flex snap-x snap-mandatory gap-4 overflow-x-auto",
        "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden",
        "pointer-fine:cursor-grab pointer-fine:data-dragging:cursor-grabbing data-dragging:select-none",
        className,
      )}
      onPointerDown={(event) => {
        onPointerDown?.(event);
        if (event.defaultPrevented) return;
        if (event.pointerType !== "mouse" || event.button !== 0) return;
        const el = viewportRef.current;
        if (!el) return;
        drag.current = {
          pointerId: event.pointerId,
          startX: event.clientX,
          startLeft: el.scrollLeft,
          moved: false,
        };
      }}
      onPointerMove={(event) => {
        const state = drag.current;
        const el = viewportRef.current;
        if (!state || !el || event.pointerId !== state.pointerId) return;
        const dx = event.clientX - state.startX;
        if (!state.moved && Math.abs(dx) > 4) {
          state.moved = true;
          el.setPointerCapture(event.pointerId);
          el.style.scrollSnapType = "none";
          el.dataset.dragging = "";
        }
        if (state.moved) {
          el.scrollLeft = state.startLeft - dx;
        }
      }}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onClickCapture={(event) => {
        if (suppressClick.current) {
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        onClickCapture?.(event);
      }}
      {...props}
    />
  );
}

export interface CarouselItemProps extends React.ComponentProps<"li"> {}

export function CarouselItem({ className, ...props }: CarouselItemProps) {
  return (
    <li
      data-slot="carousel-item"
      role="group"
      aria-roledescription="slide"
      className={cn("min-w-0 shrink-0 snap-start", className)}
      {...props}
    />
  );
}

export function CarouselPrevious({
  className,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { canPrev, index, scrollToIndex } = useCarousel("CarouselPrevious");
  return (
    <Button
      type="button"
      variant="outline"
      size="icon"
      aria-label="Previous slide"
      disabled={!canPrev}
      onClick={() => scrollToIndex(index - 1)}
      className={cn("size-8 rounded-full", className)}
      {...props}
    >
      <ArrowLeft />
    </Button>
  );
}

export function CarouselNext({
  className,
  ...props
}: React.ComponentProps<typeof Button>) {
  const { canNext, index, scrollToIndex } = useCarousel("CarouselNext");
  return (
    <Button
      type="button"
      variant="outline"
      size="icon"
      aria-label="Next slide"
      disabled={!canNext}
      onClick={() => scrollToIndex(index + 1)}
      className={cn("size-8 rounded-full", className)}
      {...props}
    >
      <ArrowRight />
    </Button>
  );
}

export interface CarouselDotsProps extends React.ComponentProps<"div"> {}

/**
 * Position dots. The active marker is one shared pill that slides
 * between dots via layout animation instead of each dot restyling.
 */
export function CarouselDots({ className, ...props }: CarouselDotsProps) {
  const { count, index, scrollToIndex } = useCarousel("CarouselDots");
  const id = React.useId();
  const reduced = useReducedMotion();

  return (
    <div
      data-slot="carousel-dots"
      aria-label="Slide picker"
      className={cn("flex items-center justify-center", className)}
      {...props}
    >
      {Array.from({ length: count }, (_, i) => (
        <button
          key={i}
          type="button"
          aria-label={`Go to slide ${i + 1}`}
          aria-current={i === index || undefined}
          onClick={() => scrollToIndex(i)}
          className="relative flex h-8 w-5 items-center justify-center"
        >
          <span
            className={cn(
              "size-1.5 rounded-full bg-muted-foreground/30 transition-colors duration-(--duration-fast)",
              i !== index && "hover:bg-muted-foreground/50",
            )}
          />
          {i === index &&
            (reduced ? (
              <span
                aria-hidden
                className="pointer-events-none absolute inset-0 m-auto h-1.5 w-4 rounded-full bg-foreground"
              />
            ) : (
              <motion.span
                aria-hidden
                layoutId={`${id}-pill`}
                transition={{ type: "spring", duration: 0.35, bounce: 0 }}
                className="pointer-events-none absolute inset-0 m-auto h-1.5 w-4 rounded-full bg-foreground"
              />
            ))}
        </button>
      ))}
    </div>
  );
}