"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useReducedMotion, useTransform, type AnimationPlaybackControls, type PanInfo } from "motion/react";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
export type SlidesPerView = number | { base: number; sm?: number; md?: number; lg?: number };
export interface CarouselProps {
children: React.ReactNode;
/** Slides visible at once, optionally per breakpoint (640 / 768 / 1024 px). */
slidesPerView?: SlidesPerView;
/** Pixels between slides. */
gap?: number;
/** Milliseconds between automatic advances; `false` disables autoplay. */
autoplay?: number | false;
/** Wrap from the last position back to the first (and vice-versa). */
loop?: boolean;
/** Accessible name of the carousel. */
label?: string;
showDots?: boolean;
showArrows?: boolean;
onIndexChange?: (index: number) => void;
className?: string;
}
function usePerView(spv: SlidesPerView) {
const query = React.useCallback(() => {
if (typeof spv === "number") return spv;
const w = window.innerWidth;
if (w >= 1024 && spv.lg) return spv.lg;
if (w >= 768 && spv.md) return spv.md;
if (w >= 640 && spv.sm) return spv.sm;
return spv.base;
}, [spv]);
return React.useSyncExternalStore(
(cb) => {
window.addEventListener("resize", cb);
return () => window.removeEventListener("resize", cb);
},
query,
() => (typeof spv === "number" ? spv : spv.base),
);
}
export function Carousel({
children,
slidesPerView = { base: 1, sm: 2, lg: 3 },
gap = 16,
autoplay = 5000,
loop = true,
label = "Featured",
showDots = true,
showArrows = true,
onIndexChange,
className,
}: CarouselProps) {
const uid = React.useId();
const reduce = useReducedMotion();
const slides = React.Children.toArray(children);
const count = slides.length;
const perView = Math.max(1, Math.min(usePerView(slidesPerView), count));
const maxIndex = Math.max(0, count - perView);
const viewportRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(0);
const [rawIndex, setIndex] = React.useState(0);
const index = Math.min(rawIndex, maxIndex);
const [playing, setPlaying] = React.useState(autoplay !== false && !reduce);
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [dragging, setDragging] = React.useState(false);
const x = useMotionValue(0);
const progress = useMotionValue(0);
const progressScale = useTransform(progress, [0, 1], [0, 1]);
const slideW = width ? (width - gap * (perView - 1)) / perView : 0;
const stride = slideW + gap;
// measure
React.useLayoutEffect(() => {
const el = viewportRef.current;
if (!el) return;
const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width));
ro.observe(el);
return () => ro.disconnect();
}, []);
// glide to index
React.useEffect(() => {
if (!stride) return;
const target = -index * stride;
if (reduce) {
x.set(target);
return;
}
const c = animate(x, target, { type: "spring", stiffness: 260, damping: 34, mass: 0.9 });
return () => c.stop();
}, [index, stride, reduce, x]);
const go = React.useCallback(
(to: number) => {
let next = to;
if (next > maxIndex) next = loop ? 0 : maxIndex;
if (next < 0) next = loop ? maxIndex : 0;
setIndex(next);
onIndexChange?.(next);
},
[maxIndex, loop, onIndexChange],
);
// autoplay with a visible progress ring on the active dot
const paused = !playing || hovered || focused || dragging || autoplay === false;
const controls = React.useRef<AnimationPlaybackControls | null>(null);
React.useEffect(() => {
if (autoplay === false) return;
progress.set(0);
controls.current = animate(progress, 1, {
duration: autoplay / 1000,
ease: "linear",
onComplete: () => go(index + 1),
});
controls.current.pause();
return () => controls.current?.stop();
}, [index, autoplay, go, progress]);
React.useEffect(() => {
if (paused) controls.current?.pause();
else controls.current?.play();
}, [paused, index]);
const onDragEnd = (_: unknown, info: PanInfo) => {
setDragging(false);
if (!stride) return;
const projected = -x.get() + -info.velocity.x * 0.25;
let next = Math.round(projected / stride);
if (next === index && Math.abs(info.offset.x) > stride * 0.15) next = index + (info.offset.x < 0 ? 1 : -1);
next = Math.max(0, Math.min(maxIndex, next));
if (next === index) {
animate(x, -index * stride, { type: "spring", stiffness: 300, damping: 32 });
} else go(next);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const map: Record<string, number> = { ArrowLeft: index - 1, ArrowRight: index + 1, Home: 0, End: maxIndex };
if (map[e.key] === undefined) return;
if ((e.target as HTMLElement).closest("input, textarea, [contenteditable]")) return;
e.preventDefault();
go(map[e.key]);
};
const canPrev = loop || index > 0;
const canNext = loop || index < maxIndex;
const positions = maxIndex + 1;
const btn =
"grid size-10 place-items-center rounded-full border bg-background/85 text-foreground shadow-md backdrop-blur outline-none transition hover:bg-background focus-visible:ring-4 focus-visible:ring-ring/30 active:scale-95 disabled:pointer-events-none disabled:opacity-0";
return (
<section
aria-roledescription="carousel"
aria-label={label}
className={cn("relative w-full", className)}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => !e.currentTarget.contains(e.relatedTarget as Node) && setFocused(false)}
onKeyDown={onKeyDown}
>
<div className="relative">
<div ref={viewportRef} className="overflow-hidden rounded-2xl">
<motion.div
id={`${uid}-track`}
aria-live={paused ? "polite" : "off"}
className={cn("flex touch-pan-y", dragging ? "cursor-grabbing" : "cursor-grab")}
style={{ x, gap }}
drag={stride ? "x" : false}
dragConstraints={{ left: -maxIndex * stride, right: 0 }}
dragElastic={0.14}
dragMomentum={false}
onDragStart={() => setDragging(true)}
onDragEnd={onDragEnd}
>
{slides.map((slide, i) => {
const visible = i >= index && i < index + perView;
return (
<div
key={i}
role="group"
aria-roledescription="slide"
aria-label={`${i + 1} of ${count}`}
inert={!visible}
className="shrink-0 select-none"
style={{ width: slideW || `calc((100% - ${gap * (perView - 1)}px) / ${perView})` }}
onDragStart={(e) => e.preventDefault()}
>
<motion.div
className="h-full"
animate={reduce ? undefined : { scale: visible ? 1 : 0.94, opacity: visible ? 1 : 0.55 }}
transition={{ type: "spring", stiffness: 260, damping: 30 }}
>
{slide}
</motion.div>
</div>
);
})}
</motion.div>
</div>
{showArrows && count > perView && (
<>
<button type="button" aria-controls={`${uid}-track`} aria-label="Previous slide" onClick={() => go(index - 1)} disabled={!canPrev} className={cn(btn, "absolute left-2 top-1/2 -translate-y-1/2 sm:-left-5")}>
<ChevronLeft className="size-5" />
</button>
<button type="button" aria-controls={`${uid}-track`} aria-label="Next slide" onClick={() => go(index + 1)} disabled={!canNext} className={cn(btn, "absolute right-2 top-1/2 -translate-y-1/2 sm:-right-5")}>
<ChevronRight className="size-5" />
</button>
</>
)}
</div>
{count > perView && (
<div className="mt-4 flex items-center justify-center gap-3">
{autoplay !== false && (
<button
type="button"
onClick={() => setPlaying((p) => !p)}
aria-label={playing ? "Stop automatic slide show" : "Start automatic slide show"}
className="grid size-8 place-items-center rounded-full border bg-background text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40"
>
{playing ? <Pause className="size-3.5" /> : <Play className="size-3.5" />}
</button>
)}
{showDots && (
<div className="flex items-center gap-1.5" role="group" aria-label="Choose slide">
{Array.from({ length: positions }, (_, i) => {
const on = i === index;
return (
<button
key={i}
type="button"
aria-label={`Go to slide ${i + 1}`}
aria-current={on ? "true" : undefined}
aria-controls={`${uid}-track`}
onClick={() => go(i)}
className="group grid h-6 place-items-center outline-none"
>
<motion.span
layout
transition={{ type: "spring", stiffness: 500, damping: 36 }}
className={cn(
"relative block h-2 overflow-hidden rounded-full group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background",
on ? "w-7 bg-foreground/15" : "w-2 bg-foreground/20 group-hover:bg-foreground/40",
)}
>
{on && (
<motion.span
className="absolute inset-0 origin-left rounded-full bg-foreground"
style={{ scaleX: autoplay === false ? 1 : progressScale }}
/>
)}
</motion.span>
</button>
);
})}
</div>
)}
<span className="sr-only" aria-live="polite">
{`Showing slide ${index + 1}${perView > 1 ? `–${index + perView}` : ""} of ${count}`}
</span>
</div>
)}
</section>
);
}