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.
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;
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>
);
}