Testimonials

Testimonials 01 — Bento Quotes

Asymmetric bento grid of testimonial cards with a featured two-row quote, five-star rating, CSS-only initials avatars and staggered reveals.

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

Code

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

"use client"

import * as React from "react"
import { motion } from "motion/react"
import { Star } from "lucide-react"

import { fadeUp, stagger, viewport } from "@/lib/motion"
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 Testimonials01Props = {
  eyebrow?: string
  title?: React.ReactNode
  description?: string
  /** The large card spanning two rows and two columns. */
  featured?: Testimonial
  testimonials?: Testimonial[]
  /** Heading element — use "h1" when this is the page's main heading. */
  as?: "h1" | "h2"
  className?: string
}

const defaultFeatured: Testimonial = {
  quote:
    "We replaced three internal dashboards and a mess of spreadsheets with Lumen in a single sprint. Six months later it's the first tab every PM opens in the morning — our weekly business review literally runs on it.",
  author: {
    initials: "ML",
    name: "Maya Lindqvist",
    role: "VP of Product, Northwind Labs",
  },
}

const defaultTestimonials: Testimonial[] = [
  {
    quote:
      "Setup took one afternoon. By Friday the whole company was looking at the same numbers.",
    author: {
      initials: "DO",
      name: "Daniel Okafor",
      role: "Head of Growth, Corely",
    },
  },
  {
    quote:
      "The fastest analytics tool I've ever used — and I've used all of them.",
    author: {
      initials: "SM",
      name: "Sofia Marchetti",
      role: "Data Lead, Pixelhaus",
    },
  },
  {
    quote:
      "Lumen paid for itself in the first month. We caught a churn signal we'd been missing for a year.",
    author: {
      initials: "JW",
      name: "James Whitmore",
      role: "CTO, Driftwave",
    },
  },
  {
    quote:
      "Our engineers actually like it, which has never once happened with a BI tool.",
    author: {
      initials: "PR",
      name: "Priya Raghavan",
      role: "Engineering Manager, Loopdesk",
    },
  },
  {
    quote: "I stopped exporting CSVs. That alone was worth the switch.",
    author: {
      initials: "TH",
      name: "Tomás Herrera",
      role: "Founder, Brightpath",
    },
  },
]

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

function AuthorLine({ author }: { author: Author }) {
  return (
    <figcaption className="flex items-center gap-3">
      <Avatar author={author} />
      <div>
        <div className="text-sm font-medium text-foreground">{author.name}</div>
        <div className="text-sm text-muted-foreground">{author.role}</div>
      </div>
    </figcaption>
  )
}

export function Testimonials01({
  eyebrow = "Testimonials",
  title = "Loved by teams who measure everything",
  description = "Don't take our word for it — here's what the people building with Lumen every day have to say.",
  featured = defaultFeatured,
  testimonials = defaultTestimonials,
  as: Heading = "h2",
  className,
}: Testimonials01Props) {
  return (
    <section className={cn("bg-background px-6 py-24", className)}>
      <div className="mx-auto max-w-6xl">
        <motion.div
          initial="hidden"
          whileInView="visible"
          viewport={viewport}
          variants={stagger()}
          className="mx-auto max-w-2xl text-center"
        >
          <motion.p
            variants={fadeUp}
            className="text-sm font-medium tracking-widest text-primary uppercase"
          >
            {eyebrow}
          </motion.p>
          <motion.div variants={fadeUp}>
            <Heading className="mt-4 text-balance font-heading text-4xl font-semibold tracking-tight md:text-5xl">
              {title}
            </Heading>
          </motion.div>
          <motion.p
            variants={fadeUp}
            className="mt-5 text-pretty text-muted-foreground md:text-lg"
          >
            {description}
          </motion.p>
        </motion.div>

        <motion.div
          initial="hidden"
          whileInView="visible"
          viewport={viewport}
          variants={stagger()}
          className="mt-14 grid gap-4 lg:grid-cols-3"
        >
          {/* Featured card — spans 2 columns and 2 rows on large screens */}
          <motion.div variants={fadeUp} className="lg:col-span-2 lg:row-span-2">
            <figure className="relative flex h-full flex-col justify-between gap-10 overflow-hidden rounded-2xl border bg-card p-8 md:p-10">
              <div
                aria-hidden
                className="absolute -top-24 -right-24 size-64 rounded-full bg-primary/10 blur-3xl"
              />
              <div className="relative">
                <span className="flex gap-1 text-primary" aria-hidden>
                  {Array.from({ length: 5 }).map((_, i) => (
                    <Star key={i} className="size-4 fill-current" />
                  ))}
                </span>
                <span className="sr-only">Rated 5 out of 5 stars</span>
                <blockquote className="mt-6 text-xl font-medium tracking-tight text-balance md:text-2xl">
                  &ldquo;{featured.quote}&rdquo;
                </blockquote>
              </div>
              <div className="relative">
                <AuthorLine author={featured.author} />
              </div>
            </figure>
          </motion.div>

          {testimonials.map((testimonial, i) => (
            <motion.div key={i} variants={fadeUp}>
              <figure className="flex h-full flex-col justify-between gap-8 rounded-2xl border bg-card p-6">
                <blockquote className="text-sm text-pretty text-foreground/90">
                  &ldquo;{testimonial.quote}&rdquo;
                </blockquote>
                <AuthorLine author={testimonial.author} />
              </figure>
            </motion.div>
          ))}
        </motion.div>
      </div>
    </section>
  )
}