# Testimonials Stack

> Layered testimonial cards with prev/next. Editable CONTENT and TESTIMONIALS.

A shadcn/ui block from the [@atroui](https://www.atroui.com/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/atroui/testimonials-stack).

- Page, with a live preview: https://shadcn.exchange/r/atroui/testimonials-stack
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/atroui/testimonials-stack-light-03e610aac88c4d60.png
- Registry item: https://www.atroui.com/r/testimonials-stack.json
- Listed: 2026-10-25, source last changed 2026-10-25

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/atroui/testimonials-stack.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `registry/default/blocks/testimonials-stack.tsx`

```tsx
"use client"

import { useState } from "react"
import { ArrowLeft, ArrowRight, Quote } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"

/**
 * Edit CONTENT / TESTIMONIALS for your social proof. Layered card stack
 * with prev/next controls and an aria-live region.
 * After install this file lives in your repo.
 */
const CONTENT = {
  stamp: "Loved by builders",
  headlineBefore: "Don't take",
  headlineAccent: "our word",
  headlineAfter: " for it.",
  lede: "Replace these with your own customer quotes.",
}

const TESTIMONIALS = [
  {
    quote:
      "We scoped on Monday and shipped the following Monday. The fixed price meant zero awkward budget calls.",
    name: "Maya Chen",
    role: "Founder, Northline",
    initials: "MC",
  },
  {
    quote:
      "It feels like a senior engineer joined for a week — PRs, docs, and opinions included.",
    name: "Jonas Weber",
    role: "CTO, Fieldwork",
    initials: "JW",
  },
  {
    quote:
      "The landing page paid for itself in the first month. Copy, design, and speed — all handled.",
    name: "Priya Nair",
    role: "CEO, Kiln",
    initials: "PN",
  },
  {
    quote:
      "Async updates every day, no meetings we didn't need. Easiest vendor experience we've had.",
    name: "Tom Okafor",
    role: "Product Lead, Harbor",
    initials: "TO",
  },
]

const EASE_SOFT = [0.32, 0.72, 0, 1] as const

export function TestimonialsStack() {
  const [index, setIndex] = useState(0)
  const [direction, setDirection] = useState(1)
  const reduce = useReducedMotion()
  const total = TESTIMONIALS.length
  const current = TESTIMONIALS[index % total]!

  const go = (dir: 1 | -1) => {
    setDirection(dir)
    setIndex((i) => (i + dir + total) % total)
  }

  return (
    <section className="@container border-t border-border-subtle bg-background text-foreground">
      <div className="border-b border-border-subtle">
        <div className="mx-auto max-w-7xl border-x border-border-subtle px-6 py-12 @min-[40rem]:px-10 @min-[40rem]:py-16">
          <div className="mx-auto max-w-2xl text-center">
            <p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
              {CONTENT.stamp}
            </p>
            <h2 className="mt-4 text-3xl font-medium tracking-tight text-foreground @min-[40rem]:text-5xl">
              {CONTENT.headlineBefore}{" "}
              <span className="italic text-brand">{CONTENT.headlineAccent}</span>
              {CONTENT.headlineAfter}
            </h2>
            <p className="mt-4 text-[15px] leading-relaxed text-muted-foreground">
              {CONTENT.lede}
            </p>
          </div>

          <div className="mx-auto mt-10 max-w-xl">
            {/* Stack only — keep controls outside so layered cards don't cover them */}
            <div className="relative">
              <div
                aria-hidden
                className="absolute inset-x-6 -top-2.5 bottom-0 rounded-[var(--atro-panel-radius)] border border-border-subtle bg-card/60"
              />
              <div
                aria-hidden
                className="absolute inset-x-3 -top-1.5 bottom-0 rounded-[var(--atro-panel-radius)] border border-border-subtle bg-card/80"
              />

              <div
                aria-live="polite"
                aria-atomic="true"
                className="relative min-h-64 overflow-hidden rounded-[var(--atro-panel-radius)] border border-border-subtle bg-card p-6 @min-[40rem]:min-h-56 @min-[40rem]:p-8"
              >
                <Quote className="size-5 text-brand" aria-hidden />
                <AnimatePresence mode="wait" initial={false}>
                  <motion.figure
                    key={index}
                    initial={reduce ? false : { opacity: 0, x: 24 * direction }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={
                      reduce ? { opacity: 0 } : { opacity: 0, x: -24 * direction }
                    }
                    transition={
                      reduce
                        ? { duration: 0 }
                        : { duration: 0.32, ease: EASE_SOFT }
                    }
                  >
                    <blockquote className="mt-4 text-lg leading-relaxed font-medium tracking-tight text-foreground @min-[40rem]:text-xl">
                      “{current.quote}”
                    </blockquote>
                    <figcaption className="mt-6 flex items-center gap-3">
                      <span
                        aria-hidden
                        className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-[11px] font-semibold text-foreground"
                      >
                        {current.initials}
                      </span>
                      <span>
                        <span className="block text-sm font-medium text-foreground">
                          {current.name}
                        </span>
                        <span className="block text-sm text-muted-foreground">
                          {current.role}
                        </span>
                      </span>
                    </figcaption>
                  </motion.figure>
                </AnimatePresence>
              </div>
            </div>

            <div className="mt-5 flex items-center justify-between">
              <p
                className="font-mono text-[11px] tabular-nums text-muted-foreground"
                aria-hidden
              >
                {String(index + 1).padStart(2, "0")} /{" "}
                {String(total).padStart(2, "0")}
              </p>
              <div className="flex items-center gap-2">
                <button
                  type="button"
                  onClick={() => go(-1)}
                  aria-label="Previous testimonial"
                  className="inline-flex size-10 items-center justify-center rounded-[var(--atro-control-radius)] border border-border-subtle text-foreground transition-colors hover:bg-muted active:scale-[0.97]"
                >
                  <ArrowLeft className="size-4" aria-hidden />
                </button>
                <button
                  type="button"
                  onClick={() => go(1)}
                  aria-label="Next testimonial"
                  className="inline-flex size-10 items-center justify-center rounded-[var(--atro-control-radius)] border border-border-subtle text-foreground transition-colors hover:bg-muted active:scale-[0.97]"
                >
                  <ArrowRight className="size-4" aria-hidden />
                </button>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  )
}
```
