bottega

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.

  • Bottega let us ship a polished site in a weekend — the components feel like our own code, not a black box.

  • Copy-and-own beats a dependency every time. We forked two components and never looked back.

  • The motion details are tasteful and the reduced-motion fallbacks just work.

  • Tokens themed everything to our brand in one file. Dark mode came for free.


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-marquee

Usage

Usagetsx
<TestimonialMarquee items={[]} />

Props

PropTypeDefaultDescription
items*TestimonialItem[]Testimonial data. Split across two rows; each row duplicated x3 for a seamless loop.
speednumber40Base scroll duration (seconds) for row 1. Row 2 auto-derives at ~0.7x speed. Default 40.
gapnumber16Gap between cards in px. Default 16.
pauseOnHoverbooleantrueWhether hovering/focusing a row pauses that entire row. Default true.
classNamestringClass override on the outer section wrapper.
cardClassNamestringClass 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