# Testimonials Stacked Cards — React Testimonial Section

> A fanned pile of quote cards: drag or click the top card to fling it to the back while the rest spring forward.

- Category: [Testimonials](https://pixelpapa.com/components/testimonials.md)
- Tags: testimonials, stack, drag, spring, cards
- Dependencies: lucide-react, motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/testimonials/testimonials-stacked-cards

## Install

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

The file is written to `components/pixelpapa/testimonials-stacked-cards.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 { ArrowLeft, ArrowRight, Quote } from "lucide-react";
import { motion, useReducedMotion, type PanInfo } from "motion/react";
import { useRef, useState } from "react";
import { cn } from "@/lib/utils";

type Story = {
  name: string;
  role: string;
  company: string;
  quote: string;
  stat: string;
  statLabel: string;
  tone: string;
};

const defaultStories: Story[] = [
  {
    name: "Margaux Lefèvre",
    role: "Studio Director",
    company: "Atelier Nord",
    quote: "Invoices used to go out when someone remembered. Now they leave the minute a milestone is approved, and we get paid nine days sooner.",
    stat: "9 days",
    statLabel: "faster payment",
    tone: "from-violet-400 to-fuchsia-500",
  },
  {
    name: "Callum Reyes",
    role: "Finance Lead",
    company: "Halfmoon Games",
    quote: "I closed the books for March in one afternoon. Last year the same job ate the first week of April.",
    stat: "4 hrs",
    statLabel: "month-end close",
    tone: "from-sky-400 to-indigo-500",
  },
  {
    name: "Adaeze Nwosu",
    role: "Founder",
    company: "Papertrail Films",
    quote: "Our clients pay in six currencies. Ledgerly is the first tool that didn't make me feel bad about that.",
    stat: "6",
    statLabel: "currencies, one ledger",
    tone: "from-emerald-400 to-teal-600",
  },
  {
    name: "Stellan Åberg",
    role: "Operations",
    company: "Kiln & Co",
    quote: "The reminder emails are polite enough that clients thank us for them. Overdue balances dropped by two thirds.",
    stat: "−67%",
    statLabel: "overdue balance",
    tone: "from-amber-400 to-rose-500",
  },
  {
    name: "Rosalind Achterberg",
    role: "Managing Partner",
    company: "Third Floor Architects",
    quote: "We stopped arguing about whose spreadsheet was right. There is one number and everyone can see where it came from.",
    stat: "1",
    statLabel: "source of truth",
    tone: "from-rose-400 to-orange-400",
  },
];

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

export default function TestimonialsStackedCards({ stories = defaultStories }: { stories?: Story[] }) {
  const [order, setOrder] = useState<number[]>(() => stories.map((_, i) => i));
  const lastDrag = useRef(0);
  const reduce = useReducedMotion();
  const top = order[0];

  const next = () => setOrder((o) => [...o.slice(1), o[0]]);
  const prev = () => setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)]);

  function onDragEnd(_: MouseEvent | TouchEvent | PointerEvent, info: PanInfo) {
    lastDrag.current = performance.now();
    if (Math.abs(info.offset.x) > 110 || Math.abs(info.velocity.x) > 650) next();
  }

  return (
    <section className="overflow-hidden bg-background px-6 py-24">
      <div className="mx-auto grid max-w-6xl items-center gap-16 lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]">
        <div>
          <p className="text-xs font-medium tracking-[0.2em] text-primary uppercase">Customer stories</p>
          <h2 className="mt-4 text-4xl font-semibold tracking-tight text-balance text-foreground sm:text-5xl">
            Five studios, one less thing to chase
          </h2>
          <p className="mt-5 max-w-md text-lg text-pretty text-muted-foreground">
            Ledgerly sends the invoice, follows up and reconciles the payment. Here is what that gave back to
            the people who used to do it by hand.
          </p>

          <div className="mt-10 flex items-center gap-4">
            <button
              type="button"
              onClick={prev}
              aria-label="Previous story"
              className="flex size-11 items-center justify-center rounded-full border border-border bg-card text-foreground transition-colors hover:bg-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
            >
              <ArrowLeft className="size-4" />
            </button>
            <button
              type="button"
              onClick={next}
              aria-label="Next story"
              className="flex size-11 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/25 transition-transform hover:-translate-y-0.5 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
            >
              <ArrowRight className="size-4" />
            </button>
            <div className="ml-2 flex items-center gap-1.5" aria-hidden>
              {stories.map((s, i) => (
                <motion.span
                  key={s.name}
                  layout
                  transition={{ type: "spring", stiffness: 400, damping: 30 }}
                  className={cn("h-1.5 rounded-full", i === top ? "w-7 bg-primary" : "w-1.5 bg-border")}
                />
              ))}
            </div>
            <p className="ml-auto font-mono text-sm text-muted-foreground tabular-nums lg:ml-2" aria-live="polite">
              <span className="text-foreground">{String(top + 1).padStart(2, "0")}</span> /{" "}
              {String(stories.length).padStart(2, "0")}
            </p>
          </div>
          <p className="mt-5 text-sm text-muted-foreground">Drag the card sideways, or just click it.</p>
        </div>

        <div className="relative mx-auto h-[27rem] w-full max-w-lg">
          {stories.map((story, i) => {
            const depth = order.indexOf(i);
            const isTop = depth === 0;
            return (
              <motion.article
                key={story.name}
                aria-hidden={!isTop}
                drag={isTop ? "x" : false}
                dragSnapToOrigin
                dragElastic={0.7}
                onDragEnd={onDragEnd}
                onClick={() => {
                  // A click that ends a drag should not also cycle the stack.
                  if (isTop && performance.now() - lastDrag.current > 250) next();
                }}
                whileDrag={{ scale: 1.03 }}
                initial={false}
                animate={{
                  y: depth * -24,
                  scale: 1 - depth * 0.06,
                  rotate: isTop ? 0 : depth % 2 ? 2.2 : -2.2,
                  opacity: depth > 3 ? 0 : 1,
                }}
                transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 260, damping: 26 }}
                style={{ zIndex: stories.length - depth, transformOrigin: "50% 0%" }}
                className={cn(
                  "absolute inset-x-0 bottom-0 flex h-[21.5rem] flex-col overflow-hidden rounded-3xl border border-border bg-card p-8 shadow-xl shadow-foreground/5 select-none",
                  isTop ? "cursor-grab touch-pan-y active:cursor-grabbing" : "pointer-events-none",
                )}
              >
                <div className="flex items-start justify-between">
                  <Quote className="size-8 fill-primary/15 text-primary/40" aria-hidden />
                  <div className="text-right">
                    <p className="text-2xl font-semibold tracking-tight text-card-foreground tabular-nums">
                      {story.stat}
                    </p>
                    <p className="text-xs text-muted-foreground">{story.statLabel}</p>
                  </div>
                </div>
                <blockquote className="mt-5 font-serif text-[1.35rem] leading-snug text-pretty text-card-foreground">
                  {story.quote}
                </blockquote>
                <footer className="mt-auto flex items-center gap-3 pt-6">
                  <span
                    aria-hidden
                    className={cn(
                      "flex size-10 items-center justify-center rounded-full bg-linear-to-br text-xs font-semibold text-white",
                      story.tone,
                    )}
                  >
                    {initials(story.name)}
                  </span>
                  <div>
                    <p className="text-sm font-medium text-card-foreground">{story.name}</p>
                    <p className="text-xs text-muted-foreground">
                      {story.role}, {story.company}
                    </p>
                  </div>
                </footer>
                {/* Cards further back sink into the page colour. */}
                <motion.div
                  aria-hidden
                  initial={false}
                  animate={{ opacity: Math.min(depth * 0.3, 0.9) }}
                  className="pointer-events-none absolute inset-0 bg-background"
                />
              </motion.article>
            );
          })}
        </div>
      </div>
    </section>
  );
}
```

## AI prompt

```text
Add the "Testimonials Stacked Cards" component from PixelPapa UI to this project.

What it is: A fanned pile of quote cards: drag or click the top card to fling it to the back while the rest spring forward.
Category: Testimonials. Depends on: lucide-react, motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/testimonials-stacked-cards.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/testimonials-stacked-cards.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/testimonials-stacked-cards" 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 Masonry Wall](https://pixelpapa.com/components/testimonials/testimonials-masonry-wall.md): 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.
