Carousel
Layout

Carousel

A snap-scroll carousel on native scroll physics with end-aware prev/next buttons, a sliding active-dot pill, desktop pointer drag with flick-to-advance, and snap-align-aware slide targets.

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;
  isStatic: 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;
}

/** Real slides only — consumers may render other children in the track. */
function slidesOf(el: HTMLElement): HTMLElement[] {
  return Array.from(el.children).filter(
    (child): child is HTMLElement =>
      child instanceof HTMLElement && child.dataset.slot === "carousel-item",
  );
}

/**
 * The scroll offset where a slide rests, honoring its own
 * `scroll-snap-align` (start, center, or end), the track's scroll-padding
 * (which insets the snapport so card shadows/rings aren't clipped), and the
 * scrollable range — so index math, buttons, and drag settle all agree with
 * the browser's snap physics.
 */
function snapLeft(el: HTMLElement, slide: HTMLElement): number {
  const align = getComputedStyle(slide).scrollSnapAlign;
  const trackStyle = getComputedStyle(el);
  const padStart = parseFloat(trackStyle.scrollPaddingLeft) || 0;
  const padEnd = parseFloat(trackStyle.scrollPaddingRight) || 0;
  // offsetLeft is measured from the track's padding edge; a start-aligned
  // slide rests where its start meets the snapport start (scroll-padding in).
  let left = slide.offsetLeft - padStart;
  if (align.includes("center")) {
    left = slide.offsetLeft - (el.clientWidth - slide.offsetWidth) / 2;
  } else if (align.includes("end")) {
    left = slide.offsetLeft - (el.clientWidth - slide.offsetWidth) + padEnd;
  }
  return Math.min(Math.max(0, el.scrollWidth - el.clientWidth), Math.max(0, left));
}

export interface CarouselProps extends React.ComponentProps<"div"> {
  /** Programmatic moves jump instantly and the active dot stops sliding. */
  static?: boolean;
}

/**
 * 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. Slide targets honor each item's
 * `scroll-snap-align`, and programmatic scrolls fall back to instant
 * jumps under reduced motion.
 */
export function Carousel({
  static: isStatic = false,
  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 = slidesOf(el);
      setCount(slides.length);
      const x = el.scrollLeft;
      let nearest = 0;
      let best = Infinity;
      slides.forEach((slide, i) => {
        const distance = Math.abs(snapLeft(el, slide) - 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 = slidesOf(el)[i];
      if (!slide) return;
      el.scrollTo({
        left: snapLeft(el, slide),
        behavior: isStatic || prefersReducedMotion() ? "auto" : "smooth",
      });
    },
    [isStatic],
  );

  const context = React.useMemo(
    () => ({ viewportRef, index, count, canPrev, canNext, isStatic, scrollToIndex }),
    [index, count, canPrev, canNext, isStatic, 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}
        <span aria-live="polite" className="sr-only">
          {count > 0 ? `Slide ${index + 1} of ${count}` : ""}
        </span>
      </div>
    </CarouselContext.Provider>
  );
}

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

interface DragState {
  pointerId: number;
  startX: number;
  startLeft: number;
  moved: boolean;
  /** Smoothed scroll velocity, px/ms — positive means scrolling forward. */
  velocity: number;
  lastX: number;
  lastTime: number;
}

/** Above this release velocity (px/ms) a drag flicks to the next slide. */
const FLICK_VELOCITY = 0.35;

export function CarouselContent({
  className,
  onPointerDown,
  onClickCapture,
  ...props
}: CarouselContentProps) {
  const { viewportRef, isStatic } = 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 target: nearest snap point — unless the release had flick
    // velocity, in which case commit one slide in the flick direction so
    // a fast short drag feels like the native inertial gesture.
    const x = el.scrollLeft;
    const targets = slidesOf(el).map((slide) => snapLeft(el, slide));
    let target = x;
    let best = Infinity;
    for (const candidate of targets) {
      const distance = Math.abs(candidate - x);
      if (distance < best) {
        best = distance;
        target = candidate;
      }
    }
    if (Math.abs(state.velocity) > FLICK_VELOCITY) {
      if (state.velocity > 0) {
        const ahead = targets.filter((candidate) => candidate > x + 1);
        if (ahead.length > 0) target = Math.min(...ahead);
      } else {
        const behind = targets.filter((candidate) => candidate < x - 1);
        if (behind.length > 0) target = Math.max(...behind);
      }
    }
    if (isStatic || 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",
        // Inset the track so card depth-shadows and focus rings aren't clipped
        // by the scroll overflow; scroll-padding keeps snap alignment honest.
        "p-1.5 scroll-px-1.5",
        "[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;
        if (drag.current) return;
        const el = viewportRef.current;
        if (!el) return;
        drag.current = {
          pointerId: event.pointerId,
          startX: event.clientX,
          startLeft: el.scrollLeft,
          moved: false,
          velocity: 0,
          lastX: event.clientX,
          lastTime: event.timeStamp,
        };
      }}
      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;
          const dt = event.timeStamp - state.lastTime;
          if (dt > 0) {
            // Scroll velocity is the inverse of pointer velocity.
            const instant = -(event.clientX - state.lastX) / dt;
            state.velocity = state.velocity * 0.6 + instant * 0.4;
          }
          state.lastX = event.clientX;
          state.lastTime = event.timeStamp;
        }
      }}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onClickCapture={(event) => {
        if (suppressClick.current) {
          event.preventDefault();
          event.stopPropagation();
          return;
        }
        onClickCapture?.(event);
      }}
      {...props}
    />
  );
}

export interface CarouselItemProps extends React.ComponentProps<"li"> {
  /** Where the slide rests in the viewport. Defaults to the leading edge. */
  align?: "start" | "center";
}

export function CarouselItem({
  align = "start",
  className,
  ...props
}: CarouselItemProps) {
  return (
    <li
      data-slot="carousel-item"
      role="group"
      aria-roledescription="slide"
      className={cn(
        "min-w-0 shrink-0",
        align === "center" ? "snap-center" : "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, isStatic, 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)}
          // Adjacent dots cap the width, so the 40px hit extension is
          // vertical-only via the after: box.
          className="group/dot relative flex h-8 w-5 items-center justify-center after:absolute after:inset-x-0 after:-inset-y-1"
        >
          <span
            className={cn(
              "size-1.5 rounded-full bg-muted-foreground/30 transition-colors duration-(--duration-fast) ease-out",
              i !== index &&
                "group-hover/dot:bg-muted-foreground/50 group-active/dot:bg-muted-foreground/70",
            )}
          />
          {i === index &&
            (reduced || isStatic ? (
              <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>
  );
}