"use client";
import * as React from "react";
import { AnimatePresence, motion, type Variant, type Variants } from "motion/react";
export interface Testimonial {
id: string;
quote: string;
author: string;
role?: string;
/** Image URL. Initials are drawn when omitted. */
avatar?: string;
}
export type TestimonialRotatorEffect = "blur" | "rise" | "fade";
export interface TestimonialRotatorProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
items: Testimonial[];
/** Advance automatically. Pauses on hover, focus, hidden tabs, offscreen and via the pause button. */
autoplay?: boolean;
/** Time each quote stays on screen, in ms. */
interval?: number;
/** How the words of a new quote arrive. */
effect?: TestimonialRotatorEffect;
/** Delay between words, in ms. */
stagger?: number;
align?: "left" | "center";
size?: "md" | "lg";
/** Draw the large accent quotation mark. */
quoteMark?: boolean;
/** Accent colour (any CSS colour). */
accent?: string;
/** Accessible name of the carousel. */
label?: string;
/** Index shown first. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
}
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeVisibility(onChange: () => void) {
document.addEventListener("visibilitychange", onChange);
return () => document.removeEventListener("visibilitychange", onChange);
}
function useDocumentHidden() {
return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
}
const EASE = [0.22, 1, 0.36, 1] as const;
const SWIPE_THRESHOLD = 48;
const WORD_HIDDEN: Record<TestimonialRotatorEffect, Variant> = {
blur: { opacity: 0, filter: "blur(10px)", y: 4 },
rise: { opacity: 0, y: "0.55em" },
fade: { opacity: 0 },
};
function initials(name: string) {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => Array.from(part)[0]?.toUpperCase() ?? "")
.join("");
}
function Avatar({ item }: { item: Testimonial }) {
if (item.avatar) {
// eslint-disable-next-line @next/next/no-img-element -- shipped component must not depend on next/image
return <img src={item.avatar} alt="" className="size-10 shrink-0 rounded-full object-cover" />;
}
return (
<span
aria-hidden="true"
className="flex size-10 shrink-0 items-center justify-center rounded-full border border-[var(--tr-accent)]/40 font-mono text-[11px] tracking-[0.08em] text-zinc-900 dark:text-zinc-100"
style={{ background: "color-mix(in oklab, var(--tr-accent) 16%, transparent)" }}
>
{initials(item.author)}
</span>
);
}
/**
* An editorial testimonial carousel. Quotes arrive word by word, the author swaps underneath,
* and an autoplay timer fills the active indicator. Rotation pauses on hover, focus, hidden
* tabs, offscreen and via a visible pause button (WCAG 2.2.2). The live region is silent while
* rotating and polite whenever the user is in control.
*/
export function TestimonialRotator({
items,
autoplay = true,
interval = 6000,
effect = "blur",
stagger = 30,
align = "left",
size = "lg",
quoteMark = true,
accent = "#FF7448",
label = "Testimonials",
defaultIndex = 0,
onIndexChange,
className = "",
style,
onKeyDown,
...props
}: TestimonialRotatorProps) {
const reduceMotion = usePrefersReducedMotion();
const documentHidden = useDocumentHidden();
const rootRef = React.useRef<HTMLElement>(null);
const progressRef = React.useRef<HTMLSpanElement>(null);
const elapsedRef = React.useRef(0);
const swipeRef = React.useRef<{ x: number; y: number } | null>(null);
const regionId = React.useId();
const count = items.length;
const [index, setIndex] = React.useState(defaultIndex);
const [userPaused, setUserPaused] = React.useState(false);
const [hovered, setHovered] = React.useState(false);
const [focused, setFocused] = React.useState(false);
const [inView, setInView] = React.useState(true);
const current = count ? ((index % count) + count) % count : 0;
const item = items[current];
// Reduced motion: no automatic rotation until the user explicitly starts it with the play button.
const [reducedStarted, setReducedStarted] = React.useState(false);
const rotationEnabled = autoplay && count > 1 && (reduceMotion ? reducedStarted : !userPaused);
const rotating = rotationEnabled && !hovered && !focused && inView && !documentHidden;
const go = (next: number) => {
if (!count) return;
const target = ((next % count) + count) % count;
elapsedRef.current = 0;
setIndex(target);
onIndexChange?.(target);
};
// Pause offscreen.
React.useEffect(() => {
const root = rootRef.current;
if (!root) return;
const observer = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)));
observer.observe(root);
return () => observer.disconnect();
}, []);
// Autoplay clock: rAF accumulates elapsed time only while rotating, so pausing keeps progress.
React.useEffect(() => {
if (!rotating) return;
let last = performance.now();
let frame = requestAnimationFrame(function tick(now) {
elapsedRef.current += now - last;
last = now;
const p = Math.min(1, elapsedRef.current / Math.max(500, interval));
progressRef.current?.style.setProperty("--tr-p", String(p));
if (p >= 1) {
elapsedRef.current = 0;
const next = (current + 1) % count;
setIndex(next);
onIndexChange?.(next);
return;
}
frame = requestAnimationFrame(tick);
});
return () => cancelAnimationFrame(frame);
}, [rotating, interval, current, count, onIndexChange]);
// Sync the indicator after renders: elapsed progress while timed, a full bar when idle.
React.useLayoutEffect(() => {
const elapsed = elapsedRef.current;
const p = rotationEnabled || elapsed > 0 ? Math.min(1, elapsed / Math.max(500, interval)) : 1;
progressRef.current?.style.setProperty("--tr-p", String(p));
});
if (!item) return null;
const toggleRotation = () => {
if (reduceMotion) setReducedStarted((s) => !s);
else setUserPaused((p) => !p);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key === "ArrowRight") {
event.preventDefault();
go(current + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
go(current - 1);
}
};
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.pointerType === "mouse") return;
swipeRef.current = { x: event.clientX, y: event.clientY };
};
const handlePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const start = swipeRef.current;
swipeRef.current = null;
if (!start) return;
const dx = event.clientX - start.x;
const dy = event.clientY - start.y;
if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy)) go(current + (dx < 0 ? 1 : -1));
};
const animated = !reduceMotion;
const words = item.quote.split(/\s+/).filter(Boolean);
const wordVariants: Variants = {
hidden: WORD_HIDDEN[effect] ?? WORD_HIDDEN.blur,
visible: { opacity: 1, filter: "blur(0px)", y: 0, transition: { duration: 0.55, ease: EASE } },
};
const quoteVariants: Variants = {
hidden: {},
visible: { transition: { staggerChildren: Math.max(0, stagger) / 1000 } },
exit: { opacity: 0, filter: effect === "blur" ? "blur(4px)" : "blur(0px)", transition: { duration: 0.2, ease: "easeIn" } },
};
const centered = align === "center";
const quoteSize =
size === "lg"
? "text-[22px] leading-[1.25] sm:text-[30px] sm:leading-[1.2]"
: "text-lg leading-[1.35] sm:text-[22px] sm:leading-[1.3]";
const quoteClass = `font-medium tracking-[-0.02em] text-zinc-900 dark:text-zinc-50 ${quoteSize}`;
const buttonClass =
"inline-flex size-9 items-center justify-center rounded-full border border-zinc-900/10 text-zinc-700 outline-hidden transition-colors hover:border-zinc-900/30 hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)] dark:border-white/12 dark:text-zinc-300 dark:hover:border-white/30 dark:hover:text-white";
return (
<section
ref={rootRef}
aria-roledescription="carousel"
aria-label={label}
className={`relative w-full ${centered ? "text-center" : "text-left"} ${className}`}
style={{ ["--tr-accent" as string]: accent, ...style }}
onKeyDown={handleKeyDown}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}}
{...props}
>
{quoteMark ? (
<svg
aria-hidden="true"
viewBox="0 0 40 30"
className={`mb-5 h-6 w-8 text-[var(--tr-accent)] ${centered ? "mx-auto" : ""}`}
fill="currentColor"
>
<path d="M0 30V17.6C0 7.9 5.2 1.9 15.6 0l1.7 4.3C11.2 6 8.4 9.5 8.2 14.6H16V30H0Zm23.5 0V17.6c0-9.7 5.2-15.7 15.6-17.6L40 4.3c-5.4 1.7-8.2 5.2-8.4 10.3h7.8V30H23.5Z" />
</svg>
) : null}
{/* Silent while rotating; polite whenever the user is in control (APG carousel pattern). */}
<div
id={regionId}
aria-live={rotating ? "off" : "polite"}
aria-atomic="true"
className="touch-pan-y select-none"
onPointerDown={handlePointerDown}
onPointerUp={handlePointerUp}
onPointerCancel={() => (swipeRef.current = null)}
>
<div role="group" aria-roledescription="slide" aria-label={`${current + 1} of ${count}`}>
{/* Every quote is stacked invisibly in one grid cell, so the height never jumps between slides. */}
<div className="grid">
{items.map((t) => (
<p key={t.id} aria-hidden="true" className={`invisible [grid-area:1/1] ${quoteClass}`}>
{t.quote}
</p>
))}
<blockquote className={`[grid-area:1/1] ${quoteClass}`}>
<p className="sr-only">{item.quote}</p>
{animated ? (
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={item.id}
aria-hidden="true"
variants={quoteVariants}
initial="hidden"
animate="visible"
exit="exit"
>
{words.map((word, i) => (
<React.Fragment key={i}>
{i > 0 ? " " : null}
<motion.span variants={wordVariants} className="inline-block">
{word}
</motion.span>
</React.Fragment>
))}
</motion.p>
</AnimatePresence>
) : (
<p aria-hidden="true">{item.quote}</p>
)}
</blockquote>
</div>
<div className={`mt-7 flex min-h-10 items-center gap-3 ${centered ? "justify-center" : ""}`}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={item.id}
className="flex items-center gap-3"
initial={animated ? { opacity: 0, y: 6 } : false}
animate={{ opacity: 1, y: 0, transition: { duration: animated ? 0.4 : 0, ease: EASE, delay: animated ? 0.1 : 0 } }}
exit={animated ? { opacity: 0, y: -6, transition: { duration: 0.18 } } : { opacity: 0, transition: { duration: 0 } }}
>
<Avatar item={item} />
<span className="flex flex-col text-left">
<span className="text-sm font-medium text-zinc-900 dark:text-zinc-100">{item.author}</span>
{item.role ? (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">{item.role}</span>
) : null}
</span>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
{count > 1 ? (
<div
className={`mt-8 flex flex-wrap items-center gap-x-4 gap-y-3 border-t border-zinc-900/10 pt-4 dark:border-white/10 ${
centered ? "justify-center" : "justify-between"
}`}
>
<div role="group" aria-label="Choose a testimonial" className="flex items-center">
{items.map((t, i) => {
const active = i === current;
return (
<button
key={t.id}
type="button"
aria-label={`Testimonial ${i + 1} of ${count}: ${t.author}`}
aria-current={active ? "true" : undefined}
aria-controls={regionId}
onClick={() => go(i)}
className="group/dot flex h-8 items-center px-1 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--tr-accent)]"
>
<span
className={`relative block h-[2px] overflow-hidden rounded-full bg-zinc-900/15 transition-[width] duration-300 group-hover/dot:bg-zinc-900/30 dark:bg-white/15 dark:group-hover/dot:bg-white/30 ${
active ? "w-10" : "w-4"
}`}
>
{active ? (
<span
key={current}
ref={progressRef}
className="absolute inset-0 origin-left bg-[var(--tr-accent)]"
// Server output depends only on props; the clock then drives --tr-p directly.
style={{ transform: `scaleX(var(--tr-p, ${autoplay ? 0 : 1}))` }}
/>
) : null}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2">
<span aria-hidden="true" className="mr-1 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums text-zinc-500 dark:text-zinc-400">
{String(current + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}
</span>
{autoplay ? (
<button
type="button"
onClick={toggleRotation}
aria-label={rotationEnabled ? "Pause automatic rotation" : "Start automatic rotation"}
className={buttonClass}
>
{rotationEnabled ? (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<rect x="4" y="3" width="2.5" height="10" rx="0.5" />
<rect x="9.5" y="3" width="2.5" height="10" rx="0.5" />
</svg>
) : (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<path d="M5 3.2v9.6a.5.5 0 0 0 .76.43l7.6-4.8a.5.5 0 0 0 0-.86l-7.6-4.8A.5.5 0 0 0 5 3.2Z" />
</svg>
)}
</button>
) : null}
<button type="button" onClick={() => go(current - 1)} aria-label="Previous testimonial" aria-controls={regionId} className={buttonClass}>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3.5 5.5 8l4.5 4.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button type="button" onClick={() => go(current + 1)} aria-label="Next testimonial" aria-controls={regionId} className={buttonClass}>
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 3.5 10.5 8 6 12.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</div>
</div>
) : null}
</section>
);
}