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.
npx shadcn@latest add @paragon/carouselAlso installs: button
"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>
);
}