← TestimonialsOpen in v0
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-02Code
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">
“{testimonial.quote}”
</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>
)
}