# Testimonial Marquee

> Two-row testimonial marquee scrolling in opposite directions, paused on hover with faded edges.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/testimonial-marquee
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/testimonial-marquee.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/testimonial-marquee-demo.tsx"
import { TestimonialMarquee } from "@/components/testimonial-marquee"

const testimonials = [
  {
    quote: "We rebuilt our marketing site in a weekend. The components just work.",
    name: "Lena Park",
    role: "Design Engineer, Nimbus",
  },
  {
    quote: "Finally a library that respects dark mode and accessibility out of the box.",
    name: "Marco Rossi",
    role: "Frontend Lead, Orbit",
  },
  { quote: "Copy, paste, tweak a few props, done. It saved us weeks.", name: "Aisha Khan", role: "Founder, Tidy" },
  { quote: "The attention to motion details is outstanding.", name: "Tom Becker", role: "Product Designer, Loop" },
  {
    quote: "Our conversion rate went up after switching the pricing section.",
    name: "Chen Wei",
    role: "Growth, Fieldnote",
  },
  { quote: "Clean code that our team is happy to maintain.", name: "Sara Novak", role: "Staff Engineer, Relay" },
  {
    quote: "It feels like it was designed by people who ship products.",
    name: "Diego Alvarez",
    role: "CTO, Parcel",
  },
  { quote: "The fastest way we have found to prototype new ideas.", name: "Yuki Tanaka", role: "Indie Hacker" },
]

export default function TestimonialMarqueeDemo() {
  return (
    <div className="flex min-h-svh flex-col justify-center bg-background py-10">
      <TestimonialMarquee testimonials={testimonials} />
    </div>
  )
}
```

## Source

```tsx title="components/testimonial-marquee.tsx"
import * as React from "react"

import { cn } from "@/lib/utils"

const keyframes = `@keyframes testimonial-marquee-x{from{transform:translateX(0)}to{transform:translateX(calc(-50% - var(--gap)/2))}}`

export type Testimonial = {
  quote: string
  name: string
  role: string
  /** Avatar URL; initials are shown when omitted */
  avatar?: string
}

export interface TestimonialMarqueeProps extends React.ComponentProps<"div"> {
  testimonials: Testimonial[]
  /** Seconds per loop */
  duration?: number
  /** Number of rows; odd rows scroll in reverse */
  rows?: number
}

function initials(name: string) {
  return name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2)
    .toUpperCase()
}

function Row({ items, duration, reverse }: { items: Testimonial[]; duration: number; reverse: boolean }) {
  return (
    <div className="flex overflow-hidden [--gap:1rem]">
      <div
        className="flex w-max shrink-0 gap-[var(--gap)] group-hover:[animation-play-state:paused]"
        style={{
          animationName: "testimonial-marquee-x",
          animationDuration: `${duration}s`,
          animationTimingFunction: "linear",
          animationIterationCount: "infinite",
          animationDirection: reverse ? "reverse" : "normal",
        }}
      >
        {[...items, ...items].map((t, i) => (
          <figure
            key={i}
            aria-hidden={i >= items.length}
            className="w-80 shrink-0 rounded-2xl border bg-card p-5 text-card-foreground"
          >
            <blockquote className="text-sm leading-relaxed text-pretty">“{t.quote}”</blockquote>
            <figcaption className="mt-4 flex items-center gap-3">
              {t.avatar ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img src={t.avatar} alt="" className="size-9 rounded-full object-cover" />
              ) : (
                <span className="inline-flex size-9 items-center justify-center rounded-full bg-muted text-xs font-medium">
                  {initials(t.name)}
                </span>
              )}
              <span>
                <span className="block text-sm font-medium">{t.name}</span>
                <span className="block text-xs text-muted-foreground">{t.role}</span>
              </span>
            </figcaption>
          </figure>
        ))}
      </div>
    </div>
  )
}

export function TestimonialMarquee({
  testimonials,
  duration = 40,
  rows = 2,
  className,
  ...props
}: TestimonialMarqueeProps) {
  const perRow = Math.ceil(testimonials.length / rows)

  return (
    <div
      className={cn(
        "group relative flex flex-col gap-4 py-4 [mask-image:linear-gradient(to_right,transparent,black_12%,black_88%,transparent)]",
        className
      )}
      {...props}
    >
      <style href="testimonial-marquee" precedence="default">
        {keyframes}
      </style>
      {Array.from({ length: rows }, (_, row) => (
        <Row
          key={row}
          items={testimonials.slice(row * perRow, (row + 1) * perRow)}
          duration={duration}
          reverse={row % 2 === 1}
        />
      ))}
    </div>
  )
}
```
