Testimonial Carousel
A testimonial carousel with a soft word-by-word quote reveal, a directional slide, an avatar picker, company wordmarks and score chips, and an autoplay hairline that pauses on hover, focus and drag. Swipe, drag or use the arrow keys.
DOM + CSS0 deps1 file · 25 KB TSXreduced-motion aware
Props
"use client";
import { Fragment, useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
/**
* TestimonialCarousel — one large quote at a time.
*
* Words rise into place with a soft blur-in, the old quote slides away in the
* direction of travel, and a hairline progress bar along the bottom edge runs
* the autoplay clock (it pauses on hover, on focus, while dragging, off-screen
* and in a hidden tab). Pick a quote from the avatar row, use the arrow
* buttons or the arrow keys, or swipe and drag. Each quote can carry a
* company wordmark (drawn as inline SVG) and a score chip. Respects
* prefers-reduced-motion (calm fades, no autoplay). No dependencies beyond
* React.
*/
export type Testimonial = {
quote: string;
name: string;
role: string;
/** Company name. Drawn as a small inline-SVG wordmark above the quote. */
company?: string;
/** 0 to 5. Shown as a star chip. */
score?: number;
/** Optional avatar image URL (https or a site path). Initials are used otherwise. */
avatar?: string;
};
export type TestimonialCarouselProps = {
testimonials: Testimonial[];
autoAdvance?: boolean;
interval?: number;
/** "inherit" (default) is transparent and takes the page's text colour; "dark" paints an OLED card, "light" a white one. */
tone?: "inherit" | "dark" | "light";
/** A #rrggbb colour used for the progress bar, the stars and the active ring. */
accent?: string;
/** Replaces the default width cap (max-w-sm). */
className?: string;
/** Accessible name of the carousel. */
label?: string;
};
const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";
const HEX = /^#[0-9a-fA-F]{6}$/;
const DEFAULT_ACCENT = "#ffb86b";
const SWIPE_PX = 56;
const IMG_OK = /^(https?:\/\/|\/(?![/\\]))/;
const CM = (pct: number) => "color-mix(in srgb, currentColor " + pct + "%, transparent)";
const RING_MASK: CSSProperties = {
padding: 1,
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
mask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
};
const TONES = {
inherit: {
surface: "transparent",
line: CM(16),
chipLine: CM(18),
ink: "currentColor",
muted: CM(62),
track: CM(10),
wash: CM(7),
shadow: "none",
btn: "tcar-btn",
chip: "",
focus: "",
},
dark: {
surface: "#0b0c0e",
line: "rgba(255,255,255,0.12)",
chipLine: "rgba(255,255,255,0.14)",
ink: "#f4f5f7",
muted: "rgba(255,255,255,0.58)",
track: "rgba(255,255,255,0.07)",
wash: "rgba(255,255,255,0.07)",
shadow: "0 40px 80px -40px rgba(0,0,0,0.85), 0 1px 0 rgba(255,255,255,0.04) inset",
btn: "bg-white/[0.05] text-white/60 hover:bg-white/10 hover:text-white focus-visible:!outline-white/80",
chip: "bg-white/[0.06]",
focus: "focus-visible:!outline-white/80 ",
},
light: {
surface: "#ffffff",
line: "rgba(10,12,16,0.12)",
chipLine: "rgba(10,12,16,0.12)",
ink: "#0b0c0e",
muted: "rgba(10,12,16,0.6)",
track: "rgba(10,12,16,0.07)",
wash: "rgba(10,12,16,0.045)",
shadow: "0 1px 2px rgba(10,12,16,0.05), 0 24px 48px -28px rgba(10,12,16,0.22)",
btn: "bg-black/[0.04] text-black/55 hover:bg-black/[0.08] hover:text-black focus-visible:!outline-black/70",
chip: "bg-black/[0.04]",
focus: "focus-visible:!outline-black/70 ",
},
} as const;
const CSS = [
".tcar-btn{background:" + CM(6) + ";color:" + CM(66) + "}",
".tcar-btn:hover{background:" + CM(13) + ";color:currentColor}",
".tcar-inh :focus-visible{outline:2px solid currentColor!important;outline-offset:2px!important}",
".tcar-slide{grid-area:1/1;min-width:0}",
".tcar-slide[data-state=idle]{visibility:hidden}",
".tcar-slide[data-state=active]{animation:tcar-in 640ms " + EASE + " both}",
".tcar-slide[data-state=leaving]{animation:tcar-out 340ms cubic-bezier(0.5,0,1,1) both;pointer-events:none}",
".tcar-slide[data-state=active] .tcar-w{animation:tcar-w 780ms " + EASE + " both;animation-delay:var(--tc-d,0ms)}",
".tcar-slide[data-state=active] .tcar-meta{animation:tcar-up 640ms " + EASE + " both;animation-delay:var(--tc-d,0ms)}",
".tcar-prog{position:absolute;left:0;bottom:0;height:2px;width:100%;transform-origin:0 50%;animation:tcar-prog 3200ms linear both}",
"@keyframes tcar-in{from{opacity:0;transform:translate3d(calc(var(--tc-o,1)*30px),0,0)}to{opacity:1;transform:none}}",
"@keyframes tcar-out{from{opacity:1;transform:none}to{opacity:0;transform:translate3d(calc(var(--tc-o,1)*-30px),0,0)}}",
"@keyframes tcar-w{from{opacity:0;transform:translate3d(0,0.5em,0);filter:blur(6px)}to{opacity:1;transform:none;filter:blur(0)}}",
"@keyframes tcar-up{from{opacity:0;transform:translate3d(0,8px,0)}to{opacity:1;transform:none}}",
"@keyframes tcar-prog{from{transform:scaleX(0)}to{transform:scaleX(1)}}",
"@keyframes tcar-fade{from{opacity:0}to{opacity:1}}",
"@keyframes tcar-fadeout{from{opacity:1}to{opacity:0}}",
"@media (prefers-reduced-motion:reduce){",
".tcar-slide[data-state=active]{animation:tcar-fade 220ms ease both}",
".tcar-slide[data-state=leaving]{animation:tcar-fadeout 160ms ease both}",
".tcar-slide[data-state] .tcar-w,.tcar-slide[data-state] .tcar-meta{animation:none!important}",
".tcar-track{transition:none!important}",
"}",
].join("\n");
function subscribeMotion(cb: () => void) {
const m = window.matchMedia("(prefers-reduced-motion: reduce)");
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
}
/** True when the visitor asked for reduced motion. False on the server. */
function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribeMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function hashOf(s: string): number {
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
return h;
}
function rgba(hex: string, a: number): string {
const n = parseInt(hex.slice(1), 16);
return "rgba(" + ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255) + "," + a + ")";
}
function initialsOf(name: string): string {
const parts = name.replace(/[^\p{L}\p{N}\s]/gu, " ").split(/\s+/).filter(Boolean);
const a = parts[0]?.[0] ?? "?";
const b = parts.length > 1 ? (parts[parts.length - 1]?.[0] ?? "") : "";
return (a + b).toUpperCase();
}
/** Six invented marks. The company name picks one, plus a type treatment. */
function Mark({ seed }: { seed: number }) {
const common = { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", "aria-hidden": true } as const;
switch (seed % 6) {
case 0:
return (
<svg {...common}>
<circle cx="8" cy="8" r="5.6" stroke="currentColor" strokeWidth="1.8" />
<circle cx="8" cy="8" r="1.9" fill="currentColor" />
</svg>
);
case 1:
return (
<svg {...common}>
<rect x="1.5" y="2.5" width="13" height="3" rx="1.5" fill="currentColor" />
<rect x="1.5" y="6.5" width="8.5" height="3" rx="1.5" fill="currentColor" />
<rect x="1.5" y="10.5" width="11" height="3" rx="1.5" fill="currentColor" />
</svg>
);
case 2:
return (
<svg {...common}>
<path d="M8 1.5 14.5 8 8 14.5 1.5 8Z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
<path d="M8 5.2 10.8 8 8 10.8 5.2 8Z" fill="currentColor" />
</svg>
);
case 3:
return (
<svg {...common}>
<path d="M1.5 12.5a6.5 6.5 0 0 1 13 0Z" fill="currentColor" />
<path d="M4.6 8.2a3.4 3.4 0 0 1 6.8 0" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
);
case 4:
return (
<svg {...common}>
<path d="M1.5 13.5 6 3l4.5 10.5Z" fill="currentColor" />
<path d="M8.5 13.5 11.5 6.5l3 7Z" fill="currentColor" opacity="0.55" />
</svg>
);
default:
return (
<svg {...common}>
<rect x="1.8" y="1.8" width="12.4" height="12.4" rx="4" stroke="currentColor" strokeWidth="1.8" />
<path d="M5 11 11 5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
}
function Wordmark({ name }: { name: string }) {
const h = hashOf(name);
const look = (h >> 3) % 3;
const type =
look === 0
? "text-[15px] font-semibold tracking-[-0.02em]"
: look === 1
? "text-[11px] font-semibold uppercase tracking-[0.2em]"
: "text-[15px] font-bold lowercase tracking-[-0.03em]";
return (
<span className="inline-flex min-w-0 items-center gap-2" role="img" aria-label={name}>
<Mark seed={h} />
<span aria-hidden className={"truncate py-[3px] leading-[1.2] " + type}>
{name}
</span>
</span>
);
}
function Star({ color }: { color: string }) {
return (
<svg width="12" height="12" viewBox="0 0 16 16" aria-hidden fill={color}>
<path d="M8 1.2l2.1 4.5 4.9.6-3.6 3.4.9 4.9L8 12.2 3.7 14.6l.9-4.9L1 6.3l4.9-.6Z" />
</svg>
);
}
function Avatar({ t, tone }: { t: Testimonial; tone: "dark" | "light" }) {
const h = hashOf(t.name) % 360;
const bg =
tone === "dark"
? "linear-gradient(140deg, hsl(" + h + " 62% 58%), hsl(" + ((h + 48) % 360) + " 58% 32%))"
: "linear-gradient(140deg, hsl(" + h + " 78% 84%), hsl(" + ((h + 48) % 360) + " 70% 68%))";
const src = t.avatar && IMG_OK.test(t.avatar) ? t.avatar : null;
return (
<span
className="flex h-full w-full items-center justify-center overflow-hidden rounded-full text-[12px] font-semibold tracking-[0.02em]"
style={{ background: bg, color: tone === "dark" ? "#fff" : "hsl(" + h + " 40% 16%)" }}
>
{src ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={src} alt="" className="h-full w-full object-cover" draggable={false} />
) : (
initialsOf(t.name)
)}
</span>
);
}
type Nav = { i: number; prev: number; dir: number; n: number };
/**
* A quote carousel: soft word-by-word reveal, directional slide, avatar picker,
* autoplay with a hairline progress bar, swipe and keyboard.
*/
export function TestimonialCarousel({
testimonials,
autoAdvance = true,
interval = 3200,
tone = "inherit",
accent = DEFAULT_ACCENT,
className,
label = "Testimonials",
}: TestimonialCarouselProps) {
const len = testimonials.length;
const T = tone === "light" ? TONES.light : tone === "dark" ? TONES.dark : TONES.inherit;
const acc = HEX.test(accent) ? accent : DEFAULT_ACCENT;
const reduced = useReducedMotion();
const [nav, setNav] = useState<Nav>({ i: 0, prev: -1, dir: 1, n: 0 });
const [userPaused, setUserPaused] = useState(false);
const [hover, setHover] = useState(false);
const [focused, setFocused] = useState(false);
const [dragging, setDragging] = useState(false);
const [inView, setInView] = useState(true);
const [tabVisible, setTabVisible] = useState(true);
const rootRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const drag = useRef<{ id: number; x: number; k: number; dx: number; moving: boolean } | null>(null);
const cur = nav.i < len ? nav.i : 0;
const prevIdx = nav.prev >= 0 && nav.prev < len && nav.prev !== cur ? nav.prev : -1;
const go = (delta: number) => {
if (len < 2) return;
setNav((s) => {
const from = s.i < len ? s.i : 0;
return { i: (from + delta + len) % len, prev: from, dir: delta >= 0 ? 1 : -1, n: s.n + 1 };
});
};
const goTo = (j: number) => {
if (j === cur || j < 0 || j >= len) return;
setNav((s) => ({ i: j, prev: s.i < len ? s.i : 0, dir: j > cur ? 1 : -1, n: s.n + 1 }));
};
// Drop the leaving slide once its exit has played.
useEffect(() => {
if (nav.prev < 0) return;
const n = nav.n;
const id = setTimeout(() => setNav((s) => (s.n === n ? { ...s, prev: -1 } : s)), 700);
return () => clearTimeout(id);
}, [nav.n, nav.prev]);
// Sleep the autoplay clock off-screen and in a hidden tab.
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const io = new IntersectionObserver((es) => {
const e = es[es.length - 1];
if (e) setInView(e.isIntersecting);
});
io.observe(el);
const vis = () => setTabVisible(document.visibilityState !== "hidden");
document.addEventListener("visibilitychange", vis);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", vis);
};
}, []);
const wants = autoAdvance && !reduced && len > 1;
const running = wants && !userPaused;
const holding = hover || focused || dragging || !inView || !tabVisible;
const ms = Math.max(1000, interval);
const onKeyDown = (e: ReactKeyboardEvent<HTMLElement>) => {
if (e.key === "ArrowRight") {
e.preventDefault();
go(1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
go(-1);
} else if (e.key === "Home" && len > 1) {
e.preventDefault();
goTo(0);
} else if (e.key === "End" && len > 1) {
e.preventDefault();
goTo(len - 1);
}
};
const moveLight = (e: ReactPointerEvent<HTMLElement>) => {
if (e.pointerType === "touch") return;
const el = e.currentTarget;
const r = el.getBoundingClientRect();
const k = el.offsetWidth / (r.width || 1);
el.style.setProperty("--tc-mx", ((e.clientX - r.left) * k).toFixed(1) + "px");
el.style.setProperty("--tc-my", ((e.clientY - r.top) * k).toFixed(1) + "px");
el.style.setProperty("--tc-lit", "1");
};
const onDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (len < 2 || (e.pointerType === "mouse" && e.button !== 0)) return;
const r = e.currentTarget.getBoundingClientRect();
drag.current = { id: e.pointerId, x: e.clientX, k: e.currentTarget.offsetWidth / (r.width || 1), dx: 0, moving: false };
};
const onMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
d.dx = (e.clientX - d.x) * d.k;
if (!d.moving && Math.abs(d.dx) > 6) {
d.moving = true;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}
if (d.moving) trackRef.current?.style.setProperty("--tc-dx", (d.dx * 0.5).toFixed(1) + "px");
};
const endDrag = (e: ReactPointerEvent<HTMLDivElement>, commit: boolean) => {
const d = drag.current;
if (!d || d.id !== e.pointerId) return;
drag.current = null;
if (!d.moving) return;
trackRef.current?.style.setProperty("--tc-dx", "0px");
setDragging(false);
if (commit && Math.abs(d.dx) >= SWIPE_PX) go(d.dx < 0 ? 1 : -1);
};
if (len === 0) return null;
const ring = (on: boolean): CSSProperties => ({
boxShadow: tone === "inherit" ? (on ? "0 0 0 2px " + acc : "none") : on ? "0 0 0 2px " + T.surface + ", 0 0 0 3.5px " + acc : "0 0 0 2px " + T.surface,
});
return (
<>
<style>{CSS}</style>
<section
ref={rootRef}
role="region"
aria-roledescription="carousel"
aria-label={label}
onKeyDown={onKeyDown}
onPointerMove={moveLight}
onPointerLeave={(e) => e.currentTarget.style.setProperty("--tc-lit", "0")}
onPointerEnter={(e) => {
if (e.pointerType !== "touch") setHover(true);
}}
onPointerOut={(e) => {
if (e.pointerType !== "touch" && !e.currentTarget.contains(e.relatedTarget as Node | null)) setHover(false);
}}
onFocus={() => setFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}}
className={"@container relative w-full " + (tone === "inherit" ? "tcar-inh " : "") + (className ?? "max-w-sm")}
style={{
borderRadius: 20,
padding: 1,
boxShadow: T.shadow,
...(tone === "inherit" ? {} : { color: T.ink }),
}}
>
<div aria-hidden className="pointer-events-none absolute inset-0" style={{ borderRadius: 20, ...RING_MASK, background: T.line }} />
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
borderRadius: 20,
...RING_MASK,
background: "radial-gradient(260px circle at var(--tc-mx, 50%) var(--tc-my, 0px), " + rgba(acc, 0.85) + ", " + rgba(acc, 0.2) + " 42%, transparent 72%)",
opacity: "var(--tc-lit, 0)",
transition: "opacity 350ms ease",
}}
/>
<div className="relative overflow-hidden" style={{ borderRadius: 19, background: T.surface }}>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(420px circle at var(--tc-mx, 50%) var(--tc-my, 0px), " + T.wash + ", transparent 62%)",
opacity: "var(--tc-lit, 0)",
transition: "opacity 350ms ease",
}}
/>
<div
aria-hidden
className="pointer-events-none absolute inset-0"
style={{ background: "radial-gradient(70% 55% at 0% 0%, " + rgba(acc, 0.1) + ", transparent 70%)" }}
/>
<div className="relative flex flex-col gap-6 px-5 pb-6 pt-5 @md:gap-8 @md:px-8 @md:pb-8 @md:pt-7">
<div
aria-live={running ? "off" : "polite"}
className={"touch-pan-y " + (dragging ? "cursor-grabbing select-none" : "cursor-grab")}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={(e) => endDrag(e, true)}
onPointerCancel={(e) => endDrag(e, false)}
onDragStart={(e) => e.preventDefault()}
>
<div
ref={trackRef}
className="tcar-track grid"
style={{
transform: "translate3d(var(--tc-dx, 0px), 0, 0)",
transition: dragging ? "none" : "transform 500ms " + EASE,
}}
>
{testimonials.map((t, idx) => {
const state = idx === cur ? "active" : idx === prevIdx ? "leaving" : "idle";
const words = ("“" + t.quote + "”").split(/\s+/);
const metaDelay = Math.min(words.length * 24, 520) + 140;
return (
<div
key={idx}
role="group"
aria-roledescription="slide"
aria-label={idx + 1 + " of " + len}
aria-hidden={state === "idle" ? true : undefined}
data-state={state}
className="tcar-slide flex flex-col gap-4 @md:gap-6"
style={{ "--tc-o": nav.dir } as CSSProperties}
>
{t.company || typeof t.score === "number" ? (
<div className="flex items-center justify-between gap-3">
{t.company ? (
<span style={{ color: T.muted }} className="min-w-0">
<Wordmark name={t.company} />
</span>
) : (
<span />
)}
{typeof t.score === "number" ? (
<span
className={"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-[12px] font-medium tabular-nums leading-none " + T.chip}
style={{ border: "1px solid " + T.chipLine }}
>
<Star color={acc} />
<span aria-hidden>{Math.max(0, Math.min(5, t.score)).toFixed(1)}</span>
<span className="sr-only">Rated {Math.max(0, Math.min(5, t.score)).toFixed(1)} out of 5</span>
</span>
) : null}
</div>
) : null}
<p className="text-[18px] font-medium leading-[1.35] tracking-[-0.015em] @md:text-[27px] @md:leading-[1.28]">
{words.map((w, wi) => (
<Fragment key={wi}>
<span
className="tcar-w inline-block"
style={{ "--tc-d": Math.min(wi * 24, 520) + 60 + "ms" } as CSSProperties}
>
{w}
</span>{" "}
</Fragment>
))}
</p>
<p
className="tcar-meta text-[13px] leading-snug"
style={{ color: T.muted, "--tc-d": metaDelay + "ms" } as CSSProperties}
>
<span className="font-semibold" style={{ color: T.ink }}>
{t.name}
</span>
<span aria-hidden> · </span>
<span className="sr-only">, </span>
{t.role}
</p>
</div>
);
})}
</div>
</div>
{len > 1 ? (
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-3">
<div role="group" aria-label="Choose a testimonial" className="-m-1 flex flex-wrap items-center p-1">
{testimonials.map((t, idx) => {
const on = idx === cur;
return (
<button
key={idx}
type="button"
aria-label={t.name + (t.company ? ", " + t.company : "")}
aria-current={on ? "true" : undefined}
onClick={() => goTo(idx)}
className={
"relative h-10 w-10 shrink-0 rounded-full transition-all duration-300 focus-visible:!z-20 focus-visible:!outline-2 focus-visible:!outline-offset-2 " + (idx === 0 ? "" : tone === "inherit" ? "ml-1 " : "-ml-2.5 @md:-ml-2 ") + +
T.focus +
(on ? "z-10" : "brightness-[0.62] hover:z-10 hover:-translate-y-0.5 hover:brightness-100")
}
style={{ ...ring(on), borderRadius: 999 }}
>
<Avatar t={t} tone={tone === "light" ? "light" : "dark"} />
</button>
);
})}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{wants ? (
<button
type="button"
aria-label={userPaused ? "Resume autoplay" : "Pause autoplay"}
onClick={() => setUserPaused((v) => !v)}
className={"flex h-10 w-10 items-center justify-center rounded-full transition-[background-color,color,transform] duration-200 active:scale-90 focus-visible:!outline-2 focus-visible:!outline-offset-2 " + T.btn}
style={{ border: "1px solid " + T.chipLine }}
>
<svg width="12" height="12" viewBox="0 0 12 12" aria-hidden fill="currentColor">
{userPaused ? <path d="M3 1.8v8.4L10 6Z" /> : <path d="M2.6 1.8h2.2v8.4H2.6ZM7.2 1.8h2.2v8.4H7.2Z" />}
</svg>
</button>
) : null}
{([-1, 1] as const).map((d) => (
<button
key={d}
type="button"
aria-label={d < 0 ? "Previous testimonial" : "Next testimonial"}
onClick={() => go(d)}
className={"flex h-10 w-10 items-center justify-center rounded-full transition-[background-color,color,transform] duration-200 active:scale-90 focus-visible:!outline-2 focus-visible:!outline-offset-2 " + T.btn}
style={{ border: "1px solid " + T.chipLine }}
>
<svg width="14" height="14" viewBox="0 0 16 16" aria-hidden fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d={d < 0 ? "M10 3 5 8l5 5" : "M6 3l5 5-5 5"} />
</svg>
</button>
))}
</div>
</div>
) : null}
</div>
{running ? (
<>
<span aria-hidden className="absolute bottom-0 left-0 h-[2px] w-full" style={{ background: T.track }} />
<span
key={nav.n + "-" + ms}
aria-hidden
className="tcar-prog"
style={{
background: acc,
animationDuration: ms + "ms",
animationPlayState: holding || userPaused ? "paused" : "running",
}}
onAnimationEnd={() => go(1)}
/>
</>
) : null}
</div>
</section>
</>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this pattern
One large quote at a time. Each quote arrives word by word with a short blur-in, the previous one slides out the way the carousel is travelling, and an avatar row lets visitors jump straight to any voice. Every testimonial can carry a company (drawn as a small inline-SVG wordmark) and a score (a star chip). Autoplay is driven by a two-pixel progress bar along the bottom edge: the bar is a CSS animation whose end event advances the slide, so hovering, focusing, dragging, scrolling the carousel off-screen or switching tabs simply pauses the animation and resumes from the same point. A pause button is always available. Swipe on touch or drag with a mouse; the track follows the pointer and commits after 56px. All slides share one grid cell, so the card never changes height between quotes. By default the card is transparent and takes the page’s text colour, with borders and controls drawn from currentColor at low opacity, so it sits on light and dark pages alike; tone=“dark” and tone=“light” paint their own surface, and a soft accent light follows the pointer along the border. The default width is max-w-sm; pass className to replace it. No dependencies beyond React.
Curator’s note
Rebuilt in October: the autoplay clock is now the progress bar itself, so pausing the bar pauses the carousel and nothing can drift out of sync.