Skip to content
sectionWeek 01Added

Testimonial Marquee

An endless, two-way scrolling wall of testimonials with ratings and avatars. Pure CSS motion that pauses on hover or focus and turns into a scrollable list for reduced motion.

Live preview

Loved by teams who ship

Hover a row to pause it. With reduced motion on, it becomes a scrollable list.

  • “We replaced three tools with this and our onboarding time dropped by half.”
    Amara OkaforHead of Ops, Northwind
  • “The cleanest handoff from design to code we have ever had. Our devs actually smile now.”
    Daniel ReyesDesign Lead, Lumen
  • “Setup took an afternoon. Support answered in minutes, not days.”
    Priya NairFounder, Sprout Studio
  • “Our conversion rate went up 18% after we rebuilt the landing page with it.”
    Tom BeckerGrowth, Fieldwork
  • “Finally a library that looks good out of the box and still bends to our brand.”
    Sofia LindqvistBrand Designer, Oslo Co.
  • “Accessible by default. Our audit came back with zero blockers.”
    Kwame MensahFrontend Engineer, Arc
  • “It feels premium without being heavy. Pages still load in under a second.”
    Mei TanakaCTO, Kumo
  • “I sent the link to our whole team and everyone asked where it came from.”
    Leo MartinsProduct Manager, Brisa
  • hover pause
  • focus pause
  • reduced motion
  • 1 or 2 rows
  • no avatar fallback
  • empty list

Quality checks

  • Chromium · light passed
  • Chromium · dark passed
  • Firefox · light passed
  • Firefox · dark passed
  • WebKit · light passed
  • WebKit · dark passed

Each run loads the page at 375, 768 and 1280 px (no horizontal overflow), fails on any console error and runs an axe WCAG 2.1 AA scan. Accessibility violations across all runs: 0 · last run .

Code

import { TestimonialMarquee } from "@/components/ui/testimonial-marquee";

const testimonials = [
  { quote: "Setup took an afternoon.", name: "Priya Nair", role: "Founder, Sprout", rating: 5 },
  { quote: "Our conversion rate went up 18%.", name: "Tom Becker", role: "Growth, Fieldwork", rating: 5 },
  // ...
];

export function SocialProof() {
  return (
    <TestimonialMarquee
      heading="Loved by teams who ship"
      subheading="Real words from real customers."
      testimonials={testimonials}
      rows={2}
      duration={45}
    />
  );
}

Props

PropTypeDefaultDescription
testimonialsTestimonial[]—{ quote, name, role?, avatarUrl?, rating? } items. Split evenly across rows.
headingstring—Optional section heading (also labels the region).
subheadingstring—Optional supporting line.
rows1 | 22Number of rows. Row two scrolls the other way.
durationnumber40Seconds per loop. Higher is slower.
pauseOnHoverbooleantruePause while hovered or focused.
ariaLabelstring"Customer testimonials"Region name when no heading is set.
classNamestring—Extra classes for the section.

Build prompt

The final prompt used to generate this component.

Build a reusable React + TypeScript + Tailwind CSS v4 "TestimonialMarquee" section component for a Next.js App Router project. It should work as a Server Component (no client JS).

Requirements:
- Props (typed interfaces): testimonials: { quote, name, role?, avatarUrl?, rating? }[], heading?, subheading?, rows (1 | 2, default 2), duration (seconds per loop), pauseOnHover (default true), ariaLabel, className.
- Each testimonial is a <figure> card with an optional 5-star rating (role="img" with an "N out of 5 stars" label), the quote in a <blockquote>, and a figcaption with avatar (image or initials fallback), name and role.
- Infinite horizontal marquee done in pure CSS: render the list twice side by side and animate translateX(0 → -50%) with a keyframe; the duplicate list is aria-hidden. Row two runs in reverse. Speed comes from a CSS variable set from the duration prop.
- Fade the edges with a mask gradient. Pause the animation on hover and on focus-within.
- prefers-reduced-motion: stop the animation, hide the duplicate list, remove the mask and make the row horizontally scrollable instead.
- Use a <section> with aria-labelledby (useId for the heading id) or aria-label when there is no heading. Return null for an empty list.
- Responsive: narrower cards and gaps on mobile. Use theme tokens (bg-surface, border-border, text-muted, accent) for light and dark mode.
Also write a demo with eight realistic testimonials across two rows.

Build notes

Problems found while building and testing it, how they were fixed, and the decisions that shaped it.

  • The first version used a fixed heading id, which would clash if two marquees were on one page. It now uses useId.
  • The duplicate list needed for the seamless loop is aria-hidden, and hidden completely under reduced motion, so each testimonial is read once.
  • The usage snippet's comment colour failed contrast in dark mode (3.67:1) in the axe scan; fixed with the high-contrast code theme.