# Testimonials Masonry Wall — React Testimonial Section

> A masonry wall mixing short quotes, a highlighted story and stat tiles; it fades out at the fold and unrolls to full height on demand.

- Category: [Testimonials](https://pixelpapa.com/components/testimonials.md)
- Tags: testimonials, masonry, wall, expand, grid
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/testimonials/testimonials-masonry-wall

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/testimonials-masonry-wall.json"
```

The file is written to `components/pixelpapa/testimonials-masonry-wall.tsx`. To install manually, run `npm install lucide-react motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { ChevronDown, Star } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useId, useState } from "react";
import { cn } from "@/lib/utils";

type WallItem =
  | { kind: "quote"; name: string; role: string; body: string; stars?: boolean }
  | { kind: "highlight"; name: string; role: string; body: string }
  | { kind: "stat"; value: string; label: string; caption: string };

const tones = [
  "from-sky-400 to-indigo-500",
  "from-rose-400 to-orange-400",
  "from-emerald-400 to-teal-600",
  "from-violet-400 to-fuchsia-500",
  "from-amber-400 to-rose-500",
  "from-cyan-400 to-blue-600",
];

const defaultItems: WallItem[] = [
  { kind: "quote", name: "Leona Whitfield", role: "Support Lead, Marlowe Bikes", body: "Our first-reply time fell from six hours to forty minutes in the first week. We didn't hire anyone. We just stopped answering the same ticket twice.", stars: true },
  { kind: "stat", value: "38 min", label: "median first reply", caption: "across 1,900 teams last quarter" },
  { kind: "quote", name: "Rafael Quintero", role: "COO, Sundry", body: "Collision detection is worth the subscription on its own." },
  { kind: "highlight", name: "Chidinma Eze", role: "Head of CX, Loop Pantry", body: "A customer wrote in to say our support felt like talking to one very well-informed person. There are fourteen of us." },
  { kind: "quote", name: "Otto Brenner", role: "Founder, Brenner Audio", body: "I run support alone. Tidepool's drafts get me through the morning queue before my coffee goes cold, and they sound like me.", stars: true },
  { kind: "quote", name: "Saanvi Kapoor", role: "Operations, Field & Fern", body: "Moved 22,000 conversations over from our old helpdesk on a Sunday. Monday looked like any other Monday." },
  { kind: "quote", name: "Gideon Asante", role: "Support Engineer, Rivet", body: "Internal notes that live next to the thread, not in a different app. Simple. Somehow nobody else did it." },
  { kind: "stat", value: "97.4%", label: "satisfaction score", caption: "from 410k rated conversations" },
  { kind: "quote", name: "Mireille Faucher", role: "Customer Success, Papier Co", body: "The handoff summary means the night shift in Lyon never asks the customer to repeat themselves. That one feature changed our reviews.", stars: true },
  { kind: "quote", name: "Declan Moriarty", role: "Director of Support, Harbourline", body: "We tried three shared inboxes before this one. It's the only one the team didn't quietly route around." },
  { kind: "quote", name: "Yasmin Haddad", role: "Community Manager, Kite Studio", body: "Tagging used to be a chore. Now it happens on its own and our monthly report writes itself." },
  { kind: "quote", name: "Per Holmgren", role: "CTO, Notchwood", body: "API is clean, webhooks fire when they say they will, and the status page has been boring for a year. Exactly what I want.", stars: true },
];

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

function Author({ name, role, tone, inverted }: { name: string; role: string; tone: string; inverted?: boolean }) {
  return (
    <figcaption className="mt-5 flex items-center gap-3">
      <span
        aria-hidden
        className={cn(
          "flex size-9 shrink-0 items-center justify-center rounded-full bg-linear-to-br text-xs font-semibold text-white",
          tone,
          inverted && "ring-2 ring-primary-foreground/40",
        )}
      >
        {initials(name)}
      </span>
      <span className="min-w-0">
        <span className="block truncate text-sm font-medium">{name}</span>
        <span className={cn("block truncate text-xs", inverted ? "text-primary-foreground/75" : "text-muted-foreground")}>
          {role}
        </span>
      </span>
    </figcaption>
  );
}

function WallCard({ item, index }: { item: WallItem; index: number }) {
  const tone = tones[index % tones.length];
  if (item.kind === "stat") {
    return (
      <div className="rounded-2xl border border-dashed border-primary/40 bg-primary/[0.06] p-6">
        <p className="text-5xl font-semibold tracking-tight text-primary tabular-nums">{item.value}</p>
        <p className="mt-2 text-sm font-medium text-foreground">{item.label}</p>
        <p className="mt-1 text-xs text-muted-foreground">{item.caption}</p>
      </div>
    );
  }
  if (item.kind === "highlight") {
    return (
      <figure className="relative overflow-hidden rounded-2xl bg-primary p-7 text-primary-foreground shadow-xl shadow-primary/25">
        <span aria-hidden className="absolute -top-6 right-3 font-serif text-[9rem] leading-none text-primary-foreground/15">
          ”
        </span>
        <blockquote className="relative font-serif text-2xl leading-snug text-pretty">{item.body}</blockquote>
        <Author name={item.name} role={item.role} tone={tone} inverted />
      </figure>
    );
  }
  return (
    <figure className="rounded-2xl border border-border bg-card p-6 text-card-foreground transition-[transform,box-shadow,border-color] duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg hover:shadow-primary/5">
      {item.stars && (
        <div className="mb-3 flex gap-0.5 text-amber-500" role="img" aria-label="5 out of 5 stars">
          {[0, 1, 2, 3, 4].map((s) => (
            <Star key={s} className="size-3.5 fill-current" />
          ))}
        </div>
      )}
      <blockquote className="text-[0.95rem] leading-relaxed text-pretty">{item.body}</blockquote>
      <Author name={item.name} role={item.role} tone={tone} />
    </figure>
  );
}

export default function TestimonialsMasonryWall({
  items = defaultItems,
  collapsedHeight = 560,
}: {
  items?: WallItem[];
  /** Height of the wall in px before "show all" is pressed. */
  collapsedHeight?: number;
}) {
  const [open, setOpen] = useState(false);
  const listId = useId();
  const reduce = useReducedMotion();

  return (
    <section className="bg-background px-6 py-20">
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-col items-start justify-between gap-6 sm:flex-row sm:items-end">
          <div>
            <p className="text-xs font-medium tracking-[0.2em] text-primary uppercase">Wall of love</p>
            <h2 className="mt-3 max-w-xl text-4xl font-semibold tracking-tight text-balance text-foreground sm:text-5xl">
              Support teams that sound like one person
            </h2>
          </div>
          <p className="max-w-xs text-sm text-pretty text-muted-foreground sm:text-right">
            Unedited notes from teams who moved their shared inbox to Tidepool this year.
          </p>
        </div>

        <motion.div
          id={listId}
          initial={false}
          animate={{ height: open ? "auto" : collapsedHeight }}
          transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 120, damping: 22 }}
          className="relative mt-12 overflow-hidden"
        >
          <div className="columns-1 gap-4 sm:columns-2 lg:columns-3 xl:columns-4">
            {items.map((item, i) => (
              <motion.div
                key={i}
                initial={reduce ? false : { opacity: 0, y: 18 }}
                whileInView={{ opacity: 1, y: 0 }}
                viewport={{ once: true, amount: 0.1 }}
                transition={{ duration: 0.55, delay: (i % 4) * 0.06, ease: [0.22, 1, 0.36, 1] }}
                className="mb-4 break-inside-avoid"
              >
                <WallCard item={item} index={i} />
              </motion.div>
            ))}
          </div>
          <motion.div
            aria-hidden
            initial={false}
            animate={{ opacity: open ? 0 : 1 }}
            className="pointer-events-none absolute inset-x-0 bottom-0 h-44 bg-linear-to-t from-background via-background/80 to-transparent"
          />
        </motion.div>

        <div className={cn("relative flex justify-center transition-[margin] duration-500", open ? "mt-4" : "-mt-5")}>
          <button
            type="button"
            aria-expanded={open}
            aria-controls={listId}
            onClick={() => setOpen((v) => !v)}
            className="inline-flex h-11 items-center gap-2 rounded-full border border-border bg-card px-5 text-sm font-medium text-foreground shadow-lg shadow-foreground/5 transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
          >
            {open ? "Show fewer" : `Read all ${items.length} notes`}
            <motion.span animate={{ rotate: open ? 180 : 0 }} transition={{ type: "spring", stiffness: 300, damping: 22 }}>
              <ChevronDown className="size-4" />
            </motion.span>
          </button>
        </div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "Testimonials Masonry Wall" component from PixelPapa UI to this project.

What it is: A masonry wall mixing short quotes, a highlighted story and stat tiles; it fades out at the fold and unrolls to full height on demand.
Category: Testimonials. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/testimonials-masonry-wall.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/testimonials-masonry-wall.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/testimonials-masonry-wall" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More testimonials

- [Testimonials Before After Metrics](https://pixelpapa.com/components/testimonials/testimonials-before-after-metrics.md) (Pro): A before / after switch that rewinds every card at once: the number rolls back, the bar slides to its old mark and the quote swaps from complaint to praise.
- [Testimonials Carousel Spotlight](https://pixelpapa.com/components/testimonials/testimonials-carousel-spotlight.md) (Pro): A looping carousel where the centre quote is sharp and lifted while its neighbours shrink, blur and dim; drag, use arrows or pick an avatar.
- [Testimonials Case Study Tabs](https://pixelpapa.com/components/testimonials/testimonials-case-study-tabs.md) (Pro): Company tabs with a sliding pill; each one swaps in a headline metric that counts up, a sparkline that draws itself and the quote behind the number.
- [Testimonials Featured Quote](https://pixelpapa.com/components/testimonials/testimonials-featured-quote.md) (Pro): One oversized serif quote whose words blur in, with an author switcher underneath that fills a progress line and advances on its own.
- [Testimonials Marquee Columns](https://pixelpapa.com/components/testimonials/testimonials-marquee-columns.md): Three columns of customer quotes drift vertically in opposite directions beside a fixed headline; hovering a column pauses it.
- [Testimonials Rating Summary](https://pixelpapa.com/components/testimonials/testimonials-rating-summary.md) (Pro): An average score that counts up beside star-breakdown bars; click a bar to filter the review list, which reflows with layout animation.
