sections
Testimonial Marquee
A dual-row infinite testimonial marquee. Row 1 scrolls left at base speed; row 2 scrolls right at ~0.7x for kinetic depth contrast. Hovering or keyboard-focusing a card pauses that entire row (animation-play-state), an edge-fade CSS mask dissolves cards in/out, and an IntersectionObserver gates playback to onscreen only. Cards show quote, star rating, author, and avatar with a floating-cameo ring.
Install
1. Register the namespace (once per project):
json
// components.json — register the @bottega namespace once
{
"registries": {
"@bottega": { "url": "https://bottega.ariacode.ca/r/{name}.json" }
}
}2. Add the component:
bash
npx shadcn add @bottega/testimonial-marqueeUsage
Usagetsx
<TestimonialMarquee items={[]} />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items* | TestimonialItem[] | — | Testimonial data. Split across two rows; each row duplicated x3 for a seamless loop. |
| speed | number | 40 | Base scroll duration (seconds) for row 1. Row 2 auto-derives at ~0.7x speed. Default 40. |
| gap | number | 16 | Gap between cards in px. Default 16. |
| pauseOnHover | boolean | true | Whether hovering/focusing a row pauses that entire row. Default true. |
| className | string | Class override on the outer section wrapper. | |
| cardClassName | string | Class override applied to each testimonial card. |
Source
testimonial-marquee.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "motion/react";
import styles from "./testimonial-marquee.module.css";
export type TestimonialItem = {
quote: string;
author: string;
role?: string;
company?: string;
avatar?: string;
rating?: 1 | 2 | 3 | 4 | 5;
};
export type TestimonialMarqueeProps = {
/** Testimonial data. Split across two rows; each row duplicated x3 for a seamless loop. */
items: TestimonialItem[];
/** Base scroll duration (seconds) for row 1. Row 2 auto-derives at ~0.7x speed. Default 40. */
speed?: number;
/** Gap between cards in px. Default 16. */
gap?: number;
/** Whether hovering/focusing a row pauses that entire row. Default true. */
pauseOnHover?: boolean;
/** Class override on the outer section wrapper. */
className?: string;
/** Class override applied to each testimonial card. */
cardClassName?: string;
};
function Stars({ rating }: { rating: 1 | 2 | 3 | 4 | 5 }) {
return (
<span className={styles.stars} role="img" aria-label={`${rating} out of 5 stars`}>
{Array.from({ length: 5 }, (_, i) => (
<svg
key={i}
viewBox="0 0 20 20"
aria-hidden="true"
className={[styles.star, i < rating ? styles.starOn : styles.starOff].join(" ")}
fill="currentColor"
>
<path d="M10 1.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79L10 14.77 4.8 17.5l.99-5.79-4.21-4.1 5.82-.85L10 1.5z" />
</svg>
))}
</span>
);
}
function initials(name: string) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((p) => p[0]?.toUpperCase() ?? "")
.join("");
}
function Card({
item,
cardClassName = "",
duplicate = false,
}: {
item: TestimonialItem;
cardClassName?: string;
/** Visual loop copies (2nd/3rd) are hidden from AT + tab order so the
testimonial isn't announced or tabbed through 3x. */
duplicate?: boolean;
}) {
const meta = [item.role, item.company].filter(Boolean).join(" · ");
return (
<li
className={[styles.card, cardClassName].filter(Boolean).join(" ")}
aria-hidden={duplicate ? "true" : undefined}
>
{/* tabIndex=0 surface so keyboard users can focus a card to read it; the
row pauses via :focus-within and the ring shows via :focus-visible.
Duplicate loop copies get tabIndex=-1 so they're not tab stops. */}
<div className={styles.cardFocus} tabIndex={duplicate ? -1 : 0}>
<span className={styles.quoteMark} aria-hidden="true">
{"“"}
</span>
{item.rating ? <Stars rating={item.rating} /> : null}
<p className={styles.quote}>{item.quote}</p>
<div className={styles.footer}>
{item.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img className={styles.avatar} src={item.avatar} alt="" aria-hidden="true" />
) : (
<span
className={[styles.avatar, styles.avatarFallback].join(" ")}
aria-hidden="true"
>
{initials(item.author)}
</span>
)}
<span className={styles.identity}>
<span className={styles.author}>{item.author}</span>
{meta ? <span className={styles.meta}>{meta}</span> : null}
</span>
</div>
</div>
</li>
);
}
function Row({
items,
direction,
cardClassName,
style,
}: {
items: TestimonialItem[];
direction: "left" | "right";
cardClassName?: string;
style?: React.CSSProperties;
}) {
// Triple the list so the loop is seamless across ultrawide viewports.
const copies = [0, 1, 2];
return (
<ul
className={[
styles.track,
direction === "left" ? styles.trackLeft : styles.trackRight,
].join(" ")}
style={style}
>
{copies.map((c) =>
items.map((item, i) => (
<Card
key={`${c}-${i}`}
item={item}
cardClassName={cardClassName}
duplicate={c > 0}
/>
)),
)}
</ul>
);
}
export function TestimonialMarquee({
items,
speed = 40,
gap = 16,
pauseOnHover = true,
className = "",
cardClassName = "",
}: TestimonialMarqueeProps) {
const regionRef = useRef<HTMLElement>(null);
// Mount-gate + null-safe reduce check (contract snippet). Server HTML and the
// first client render are byte-identical static (data-animate omitted); the
// marquee animation turns on only after hydration for non-reduced users.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const prefersReducedMotion = useReducedMotion();
const reduce = prefersReducedMotion === null || prefersReducedMotion; // null => reduced
const animate = mounted && !reduce;
// IntersectionObserver gate — only run the animation when in view.
const [inView, setInView] = useState(false);
useEffect(() => {
if (!animate) return;
const el = regionRef.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ threshold: 0 },
);
io.observe(el);
return () => io.disconnect();
}, [animate]);
// Split items into two rows (even / odd) so each plane reads differently.
const rowOne = items.filter((_, i) => i % 2 === 0);
const rowTwo = items.filter((_, i) => i % 2 === 1);
// Row 2 is slower (0.7x speed => longer duration). Numeric seconds, not the
// --duration CSS token (which is for ~300ms UI transitions).
const rowTwoDuration = speed / 0.7;
return (
<section
ref={regionRef}
role="region"
aria-label="Customer testimonials"
data-slot="testimonial-marquee"
data-animate={animate ? "true" : undefined}
data-pause={pauseOnHover ? "true" : "false"}
data-inview={inView ? "true" : "false"}
className={[styles.region, className].filter(Boolean).join(" ")}
style={
{
"--marquee-duration": `${speed}s`,
"--row-gap": `${gap}px`,
} as React.CSSProperties
}
>
<div className={styles.viewport}>
<Row items={rowOne} direction="left" cardClassName={cardClassName} />
<Row
items={rowTwo.length ? rowTwo : rowOne}
direction="right"
cardClassName={cardClassName}
style={{ "--marquee-duration": `${rowTwoDuration}s` } as React.CSSProperties}
/>
</div>
</section>
);
}
Dependencies
- motion
- @bottega/tokens