Testimonials

Testimonials 02 — Marquee Walls

Two infinite marquee rows of compact quote cards scrolling in opposite directions, with edge fade, pause on hover and a revealed section heading.

$ npx shadcn@latest add @ludusvibe/testimonials-02
Open in v0

Code

Installed to components/sections/testimonials-02.tsx — it's yours to edit.

"use client"

import * as React from "react"

import { Marquee } from "@/components/ludus/marquee"
import { Reveal } from "@/components/ludus/reveal"
import { cn } from "@/lib/utils"

type Author = {
  /** Shown in the CSS-only avatar circle when `src` is omitted. */
  initials: string
  name: string
  role: string
  /** Optional real photo URL — replaces the initials circle. */
  src?: string
}

type Testimonial = {
  quote: string
  author: Author
}

export type Testimonials02Props = {
  eyebrow?: string
  title?: React.ReactNode
  description?: string
  /** Split across the two marquee rows — first half scrolls left, second half scrolls right. */
  testimonials?: Testimonial[]
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const defaultTestimonials: Testimonial[] = [
  {
    quote: "Lumen replaced four tools on day one. Nobody misses any of them.",
    author: { initials: "EV", name: "Elena Vasquez", role: "COO, Fernbrook" },
  },
  {
    quote:
      "The onboarding was so smooth I kept waiting for the catch. Still waiting.",
    author: { initials: "MC", name: "Marcus Chen", role: "Product Lead, Tidepool" },
  },
  {
    quote: "Finally — dashboards our executives actually open on their own.",
    author: { initials: "AD", name: "Amara Diallo", role: "Head of Data, Veldt" },
  },
  {
    quote: "We shipped our quarterly review two weeks early. Two. Weeks.",
    author: { initials: "JW", name: "Jonas Weber", role: "CFO, Klarhet" },
  },
  {
    quote: "Support answered in four minutes. On a Sunday.",
    author: { initials: "RK", name: "Rachel Kim", role: "Founder, Mosslight" },
  },
  {
    quote:
      "I've stopped screenshotting charts into Slack. Lumen just does it for me.",
    author: { initials: "OB", name: "Oliver Bennett", role: "Growth Manager, Farecast" },
  },
  {
    quote: "Every metric, one place, zero SQL. My whole team is obsessed.",
    author: { initials: "IS", name: "Ingrid Solberg", role: "VP Marketing, Nordvik" },
  },
  {
    quote: "Switching took an afternoon. Should have done it a year ago.",
    author: { initials: "DF", name: "Diego Fuentes", role: "CTO, Palomar" },
  },
  {
    quote: "The free tier alone beats what we were paying five figures for.",
    author: { initials: "HL", name: "Hannah Levi", role: "Product Manager, Orchard & Co" },
  },
  {
    quote: "Our board deck basically builds itself now.",
    author: { initials: "SW", name: "Sam Whitaker", role: "CEO, Bluechalk" },
  },
]

function Avatar({ author }: { author: Author }) {
  if (author.src) {
    return (
      <img
        src={author.src}
        alt={author.name}
        className="size-9 shrink-0 rounded-full border object-cover"
      />
    )
  }
  return (
    <span
      aria-hidden
      className="flex size-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-medium text-primary"
    >
      {author.initials}
    </span>
  )
}

function QuoteCard({ testimonial }: { testimonial: Testimonial }) {
  return (
    <figure className="w-72 shrink-0 rounded-xl border bg-card p-5 sm:w-80">
      <blockquote className="text-sm text-pretty text-foreground/90">
        &ldquo;{testimonial.quote}&rdquo;
      </blockquote>
      <figcaption className="mt-4 flex items-center gap-3">
        <Avatar author={testimonial.author} />
        <div>
          <div className="text-sm font-medium text-foreground">
            {testimonial.author.name}
          </div>
          <div className="text-xs text-muted-foreground">
            {testimonial.author.role}
          </div>
        </div>
      </figcaption>
    </figure>
  )
}

export function Testimonials02({
  eyebrow = "Wall of love",
  title = "The internet has opinions. Good ones.",
  description = "Hundreds of teams have made the switch to Lumen — here are a few of their words, on loop.",
  testimonials = defaultTestimonials,
  as: Heading = "h2",
  className,
}: Testimonials02Props) {
  const midpoint = Math.ceil(testimonials.length / 2)
  const firstRow = testimonials.slice(0, midpoint)
  const secondRow = testimonials.slice(midpoint)

  return (
    <section className={cn("overflow-hidden bg-background py-24", className)}>
      <div className="mx-auto max-w-2xl px-6 text-center">
        <Reveal>
          <p className="text-sm font-medium tracking-widest text-primary uppercase">
            {eyebrow}
          </p>
        </Reveal>
        <Reveal delay={0.1}>
          <Heading className="mt-4 text-balance font-heading text-4xl font-semibold tracking-tight md:text-5xl">
            {title}
          </Heading>
        </Reveal>
        <Reveal delay={0.2}>
          <p className="mt-5 text-pretty text-muted-foreground md:text-lg">
            {description}
          </p>
        </Reveal>
      </div>

      <Reveal delay={0.3} className="mt-14 flex flex-col gap-5">
        <Marquee duration={45} pauseOnHover>
          {firstRow.map((testimonial, i) => (
            <QuoteCard key={i} testimonial={testimonial} />
          ))}
        </Marquee>
        <Marquee duration={45} reverse pauseOnHover>
          {secondRow.map((testimonial, i) => (
            <QuoteCard key={i} testimonial={testimonial} />
          ))}
        </Marquee>
      </Reveal>
    </section>
  )
}