"use client";
import * as React from "react";
export interface SnapCarouselSlide {
/** Unique key for the slide. */
id: string;
/** Slide content. The slide wrapper only sets width and snapping; style the card yourself. */
content: React.ReactNode;
/** Short accessible name, announced with the position, e.g. "Arc Lamp". */
label?: string;
/** Small preview rendered in the thumbnail picker. Falls back to the slide number. */
thumbnail?: React.ReactNode;
}
export interface SnapCarouselProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
slides: SnapCarouselSlide[];
/** Accessible name of the carousel region. */
label?: string;
/** Slides per view in a wide container. Narrow containers drop to 2, then 1 (container queries). */
slidesPerView?: number;
/** How much of the next slide peeks into view, in px. */
peek?: number;
/** Gap between slides in px. */
gap?: number;
/** Where slides snap: to the start edge or the centre of the viewport. */
align?: "start" | "center";
/** Slide picker under the track. */
indicators?: "dots" | "thumbnails" | "none";
/** Advance automatically until the last slide (no loop). Shows a pause / play control. */
autoplay?: boolean;
/** Time per slide while autoplaying, in ms. */
interval?: number;
/** Accent for the active indicator, autoplay ring and focus rings. */
accent?: string;
/** Called when the active slide changes (after scrolling settles on it). */
onIndexChange?: (index: number) => void;
}
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeVisibility(callback: () => void) {
document.addEventListener("visibilitychange", callback);
return () => document.removeEventListener("visibilitychange", callback);
}
function useDocumentHidden() {
return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
}
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const pad = (n: number) => String(n).padStart(2, "0");
const RING = 2 * Math.PI * 9; // circumference of the autoplay progress ring (r = 9)
/**
* A carousel on native CSS scroll-snap: the browser does the swiping, snapping and momentum, while
* IntersectionObserver reports which slides are in view. Prev / next buttons disable at the ends, the
* dot or thumbnail picker follows the active slide, slides-per-view responds to the container width, and
* optional autoplay runs once to the end with a visible pause control (WCAG 2.2.2).
*/
export function SnapCarousel({
slides,
label = "Carousel",
slidesPerView = 3,
peek = 40,
gap = 16,
align = "start",
indicators = "dots",
autoplay = false,
interval = 5000,
accent = "#FF7448",
onIndexChange,
className = "",
style,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: SnapCarouselProps) {
const id = React.useId();
const reduceMotion = usePrefersReducedMotion();
const documentHidden = useDocumentHidden();
const rootRef = React.useRef<HTMLElement>(null);
const scrollerRef = React.useRef<HTMLDivElement>(null);
const toggleRef = React.useRef<HTMLButtonElement>(null);
const ringRef = React.useRef<SVGCircleElement>(null);
const animationRef = React.useRef<Animation | null>(null);
const ratiosRef = React.useRef<number[]>([]);
const activeRef = React.useRef(0);
// Slide we are scrolling towards, so rapid clicks step from the target rather than the stale position.
const pendingRef = React.useRef<number | null>(null);
const count = slides.length;
const perView = clamp(Math.round(slidesPerView), 1, 6);
const [active, setActive] = React.useState(0);
const [range, setRange] = React.useState<[number, number]>([0, 0]);
const [edges, setEdges] = React.useState({ prev: false, next: count > 1 });
const [inView, setInView] = React.useState(true);
const [hovered, setHovered] = React.useState(false);
const [focusInside, setFocusInside] = React.useState(false);
const [announce, setAnnounce] = React.useState(false);
// Autoplay starts on its own, except under reduced motion where it waits for the play button.
const [playing, setPlaying] = React.useState(autoplay);
const [userToggled, setUserToggled] = React.useState(false);
const [prevAutoplay, setPrevAutoplay] = React.useState(autoplay);
if (prevAutoplay !== autoplay) {
setPrevAutoplay(autoplay);
setPlaying(autoplay);
setUserToggled(false);
}
const isPlaying = autoplay && playing && (!reduceMotion || userToggled);
const running = isPlaying && !hovered && !focusInside && inView && !documentHidden;
/* ----------------------------- active tracking ---------------------------- */
const reportIndex = React.useEffectEvent((index: number) => onIndexChange?.(index));
React.useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const nodes = Array.from(scroller.children) as HTMLElement[];
ratiosRef.current = nodes.map(() => 0);
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
const index = Number((entry.target as HTMLElement).dataset.index);
ratiosRef.current[index] = entry.intersectionRatio;
}
const ratios = ratiosRef.current;
const visible = ratios.flatMap((ratio, i) => (ratio >= 0.9 ? [i] : []));
let next: number;
if (visible.length) next = align === "center" ? visible[Math.floor((visible.length - 1) / 2)] : visible[0];
else next = ratios.reduce((best, ratio, i) => (ratio > ratios[best] ? i : best), 0);
if (pendingRef.current === next) pendingRef.current = null;
const first = visible[0] ?? next;
const last = visible[visible.length - 1] ?? next;
setRange((prev) => (prev[0] === first && prev[1] === last ? prev : [first, last]));
if (activeRef.current !== next) {
activeRef.current = next;
setActive(next);
reportIndex(next);
}
},
{ root: scroller, threshold: [0, 0.25, 0.5, 0.75, 0.9, 1] },
);
nodes.forEach((node) => observer.observe(node));
return () => observer.disconnect();
}, [count, align]);
// Prev / next availability follows the real scroll position (rAF-throttled, state only on change).
React.useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
let frame = 0;
const measure = () => {
frame = 0;
const max = scroller.scrollWidth - scroller.clientWidth;
const prev = scroller.scrollLeft > 2;
const next = scroller.scrollLeft < max - 2;
setEdges((e) => (e.prev === prev && e.next === next ? e : { prev, next }));
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
scroller.addEventListener("scroll", schedule, { passive: true });
const ro = new ResizeObserver(schedule);
ro.observe(scroller);
return () => {
scroller.removeEventListener("scroll", schedule);
ro.disconnect();
cancelAnimationFrame(frame);
};
}, [count]);
// Autoplay only runs while the carousel is on screen.
React.useEffect(() => {
const root = rootRef.current;
if (!root || !autoplay) return;
const observer = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
observer.observe(root);
return () => observer.disconnect();
}, [autoplay]);
/* -------------------------------- navigation ------------------------------- */
const scrollToIndex = (index: number) => {
const scroller = scrollerRef.current;
const slide = scroller?.children[index] as HTMLElement | undefined;
if (!scroller || !slide) return;
const max = scroller.scrollWidth - scroller.clientWidth;
const left =
align === "center" ? slide.offsetLeft + slide.offsetWidth / 2 - scroller.clientWidth / 2 : slide.offsetLeft;
pendingRef.current = index;
scroller.scrollTo({ left: clamp(left, 0, max), behavior: reduceMotion ? "auto" : "smooth" });
};
const atEnd = () => {
const scroller = scrollerRef.current;
return !scroller || scroller.scrollLeft >= scroller.scrollWidth - scroller.clientWidth - 2;
};
/** Moves by one slide. Returns false when there is nowhere to go. */
const step = (direction: 1 | -1) => {
const from = pendingRef.current ?? active;
const target = clamp(from + direction, 0, count - 1);
if (target === from || (direction > 0 && atEnd() && pendingRef.current === null)) return false;
scrollToIndex(target);
return true;
};
const userNavigate = (fn: () => void) => {
setAnnounce(true);
fn();
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
const keys: Record<string, () => void> = {
ArrowRight: () => step(1),
ArrowLeft: () => step(-1),
Home: () => scrollToIndex(0),
End: () => scrollToIndex(count - 1),
};
const action = keys[event.key];
if (!action) return;
event.preventDefault();
userNavigate(action);
};
/* --------------------------------- autoplay -------------------------------- */
const advance = React.useEffectEvent(() => {
setAnnounce(false);
if (!step(1)) setPlaying(false);
});
// One Web Animation per slide drives the progress ring; finishing it advances the carousel.
// Pausing the animation (hover, focus, hidden tab, offscreen) pauses the timer at the same point.
React.useEffect(() => {
const ring = ringRef.current;
if (!isPlaying || !ring) return;
const animation = ring.animate([{ strokeDashoffset: RING }, { strokeDashoffset: 0 }], {
duration: interval,
easing: "linear",
fill: "both",
});
animation.pause();
animation.onfinish = () => advance();
animationRef.current = animation;
return () => {
animation.onfinish = null;
animation.cancel();
animationRef.current = null;
};
}, [isPlaying, active, interval]);
React.useEffect(() => {
const animation = animationRef.current;
if (!animation) return;
if (running) animation.play();
else animation.pause();
}, [running, isPlaying, active, interval]);
const togglePlaying = () => {
setUserToggled(true);
if (!isPlaying && atEnd()) {
// Finished: start again from the first slide.
scrollToIndex(0);
}
setPlaying(!isPlaying);
};
/* ---------------------------------- render --------------------------------- */
const transitionDots = reduceMotion ? "" : "transition-[width,background-color,opacity] duration-300";
const ringFocus =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sc-accent)]";
const arrowClass = `flex size-9 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-700 shadow-[0_1px_2px_rgba(0,0,0,0.05)] transition-[color,border-color,opacity] hover:border-zinc-900/25 hover:text-zinc-950 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:border-zinc-900/10 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-300 dark:hover:border-white/25 dark:hover:text-white dark:disabled:hover:border-white/10 ${ringFocus}`;
const showRange = range[1] > range[0];
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`@container w-full min-w-0 ${className}`}
style={
{
"--sc-accent": accent,
"--sc-max": perView,
"--sc-gap": `${gap}px`,
"--sc-peek": `${align === "center" ? peek * 2 : peek}px`,
...style,
} as React.CSSProperties
}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") setHovered(true);
onPointerEnter?.(event);
}}
onPointerLeave={(event) => {
setHovered(false);
onPointerLeave?.(event);
}}
onFocus={(event) => {
// Focus anywhere except the pause / play button pauses rotation.
setFocusInside(event.target !== toggleRef.current);
onFocus?.(event);
}}
onBlur={(event) => {
const next = event.relatedTarget as Node | null;
if (!next || !event.currentTarget.contains(next)) setFocusInside(false);
onBlur?.(event);
}}
{...props}
>
<div
ref={scrollerRef}
id={`${id}-track`}
tabIndex={0}
role="group"
aria-label={`${label} slides`}
aria-describedby={`${id}-hint`}
onKeyDown={handleKeyDown}
onPointerDown={() => (pendingRef.current = null)}
onWheel={() => (pendingRef.current = null)}
className={`relative flex snap-x snap-mandatory overflow-x-auto overflow-y-hidden overscroll-x-contain rounded-2xl [scrollbar-width:none] [&::-webkit-scrollbar]:hidden [--sc-per:1] @min-[440px]:[--sc-per:min(2,var(--sc-max))] @min-[720px]:[--sc-per:var(--sc-max)] ${ringFocus}`}
style={{ gap: "var(--sc-gap)" }}
>
{slides.map((slide, index) => (
<div
key={slide.id}
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} of ${count}${slide.label ? `: ${slide.label}` : ""}`}
data-index={index}
className={`min-w-0 shrink-0 ${align === "center" ? "snap-center" : "snap-start"}`}
style={{
width: "calc((100% - var(--sc-peek) - (var(--sc-per) - 1) * var(--sc-gap)) / var(--sc-per))",
}}
>
{slide.content}
</div>
))}
</div>
<div className="mt-4 flex items-center gap-3">
{autoplay && (
<button
ref={toggleRef}
type="button"
onClick={togglePlaying}
aria-label={isPlaying ? "Pause automatic slide show" : "Start automatic slide show"}
aria-controls={`${id}-track`}
className={`relative flex size-9 shrink-0 items-center justify-center rounded-full text-zinc-700 transition-colors hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white ${ringFocus}`}
>
<svg viewBox="0 0 22 22" className="absolute inset-0 size-9 -rotate-90" aria-hidden="true">
<circle cx="11" cy="11" r="9" fill="none" strokeWidth="1.25" className="stroke-zinc-900/12 dark:stroke-white/15" />
<circle
ref={ringRef}
cx="11"
cy="11"
r="9"
fill="none"
strokeWidth="1.5"
strokeLinecap="round"
stroke="var(--sc-accent)"
strokeDasharray={RING.toFixed(2)}
strokeDashoffset={RING.toFixed(2)}
className={isPlaying ? "" : "opacity-0"}
/>
</svg>
<svg viewBox="0 0 16 16" className="relative size-3.5" fill="currentColor" aria-hidden="true">
{isPlaying ? (
<path d="M4.5 3h2.25v10H4.5zM9.25 3h2.25v10H9.25z" />
) : (
<path d="M5 2.8v10.4a.5.5 0 0 0 .77.42l8-5.2a.5.5 0 0 0 0-.84l-8-5.2A.5.5 0 0 0 5 2.8Z" />
)}
</svg>
</button>
)}
{indicators !== "none" && count > 1 && (
<div
role="group"
aria-label="Choose slide"
className={`flex min-w-0 flex-1 items-center ${indicators === "thumbnails" ? "gap-2 overflow-x-auto p-0.5 [scrollbar-width:none]" : "gap-1.5"}`}
>
{slides.map((slide, index) => {
const isActive = index === active;
const inRange = index >= range[0] && index <= range[1];
const name = `Slide ${index + 1}${slide.label ? `: ${slide.label}` : ""}`;
return indicators === "thumbnails" ? (
<button
key={slide.id}
type="button"
aria-label={name}
aria-current={isActive ? "true" : undefined}
aria-controls={`${id}-track`}
onClick={() => userNavigate(() => scrollToIndex(index))}
className={`relative size-12 shrink-0 overflow-hidden rounded-lg border bg-zinc-100 dark:bg-zinc-900 ${transitionDots} ${
isActive
? "border-[var(--sc-accent)] opacity-100"
: inRange
? "border-zinc-900/15 opacity-90 dark:border-white/15"
: "border-zinc-900/10 opacity-55 hover:opacity-90 dark:border-white/10"
} ${ringFocus}`}
>
{slide.thumbnail ?? (
<span className="font-mono text-[10px] tabular-nums text-zinc-500 dark:text-zinc-400">{pad(index + 1)}</span>
)}
{isActive && <span aria-hidden="true" className="absolute inset-x-1.5 bottom-1 h-[2px] rounded-full bg-[var(--sc-accent)]" />}
</button>
) : (
<button
key={slide.id}
type="button"
aria-label={name}
aria-current={isActive ? "true" : undefined}
aria-controls={`${id}-track`}
onClick={() => userNavigate(() => scrollToIndex(index))}
// 24px hit area around a small visual dot.
className={`group flex h-6 items-center justify-center rounded-full ${isActive ? "w-7" : "w-4"} ${transitionDots} ${ringFocus}`}
>
<span
aria-hidden="true"
className={`block h-1.5 rounded-full ${transitionDots} ${
isActive
? "w-6 bg-[var(--sc-accent)]"
: inRange
? "w-1.5 bg-zinc-900/45 dark:bg-white/50"
: "w-1.5 bg-zinc-900/15 group-hover:bg-zinc-900/35 dark:bg-white/20 dark:group-hover:bg-white/40"
}`}
/>
</button>
);
})}
</div>
)}
{(indicators === "none" || count <= 1) && <div className="flex-1" />}
<p aria-hidden="true" className="shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400">
<span className="text-zinc-900 dark:text-zinc-100">{showRange ? `${pad(range[0] + 1)}–${pad(range[1] + 1)}` : pad(active + 1)}</span>
{" / "}
{pad(count)}
</p>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
aria-label="Previous slide"
aria-controls={`${id}-track`}
disabled={!edges.prev}
onClick={() => userNavigate(() => step(-1))}
className={arrowClass}
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" aria-hidden="true">
<path d="M10 3.5 5.5 8l4.5 4.5" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
aria-label="Next slide"
aria-controls={`${id}-track`}
disabled={!edges.next}
onClick={() => userNavigate(() => step(1))}
className={arrowClass}
>
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" aria-hidden="true">
<path d="M6 3.5 10.5 8 6 12.5" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
<span id={`${id}-hint`} className="sr-only">
Use the Left and Right arrow keys to move between slides.
</span>
{/* Announces the position after user navigation; silent while autoplay rotates (APG). */}
<div role="status" aria-live={running ? "off" : "polite"} aria-atomic="true" className="sr-only">
{announce ? `Slide ${active + 1} of ${count}${slides[active]?.label ? `: ${slides[active].label}` : ""}` : ""}
</div>
</section>
);
}