Three Column Testimonial Wall with Vertical Scroll

A dark testimonial section with a wall of review cards beside the headline. The three columns scroll vertically at different speeds, the middle one in the opposite direction, and fade out at the top and bottom. Hovering a column pauses just that column so the card can be read, and a button pauses the whole wall. Tablets show two columns and phones show one, with the headline stacked above.

Code

import { useEffect, useRef, useState } from "react";

type Review = { quote: string; name: string; role: string; tone: string; stat?: string };

// Three columns of reviews. Each column loops on its own, so give them different lengths.
const columns: { seconds: number; reverse: boolean; show: string; reviews: Review[] }[] = [
  {
    seconds: 44,
    reverse: false,
    show: "block",
    reviews: [
      { quote: "We closed the books in two days instead of nine. I did not think that sentence was possible for a company our size.", name: "Imani Okafor", role: "Controller, Brightwater Foods", tone: "bg-lime-300 text-lime-950", stat: "9 days to 2" },
      { quote: "The reconciliation view is the first finance screen my team has ever described as calm.", name: "Tobias Lindqvist", role: "Head of Finance, Sorrel", tone: "bg-emerald-300 text-emerald-950" },
      { quote: "Set up on a Tuesday, ran payroll through it on the Friday. Nobody had to read a manual.", name: "Priya Ramanathan", role: "COO, Fernhill Studio", tone: "bg-teal-300 text-teal-950" },
      { quote: "Our auditors asked which firm built our reports. It was a template and forty minutes of my afternoon.", name: "Marcus Bellweather", role: "Founder, Outpost Cycles", tone: "bg-amber-300 text-amber-950" },
    ],
  },
  {
    seconds: 58,
    reverse: true,
    show: "hidden sm:block",
    reviews: [
      { quote: "I used to dread the first week of every month. Now it is a checklist that mostly ticks itself.", name: "Hana Kobayashi", role: "Finance Lead, Tallgrass", tone: "bg-emerald-300 text-emerald-950" },
      { quote: "Support answered in six minutes on a Sunday, with the fix and a short video of the fix.", name: "Dele Adeyemi", role: "Operations, Kestrel Supply", tone: "bg-lime-300 text-lime-950", stat: "6 min reply" },
      { quote: "It caught a duplicate vendor payment in the first hour. That one catch paid for three years of the plan.", name: "Sofia Marchetti", role: "CFO, Portside Coffee", tone: "bg-amber-300 text-amber-950" },
      { quote: "Clean, fast and honest about what it does not do yet. I trust it more because of that.", name: "Callum Fraser", role: "Bookkeeper, Fraser and Daughters", tone: "bg-teal-300 text-teal-950" },
    ],
  },
  {
    seconds: 50,
    reverse: false,
    show: "hidden lg:block",
    reviews: [
      { quote: "Every investor update now starts from a live number, not a spreadsheet somebody exported last week.", name: "Nadia Haddad", role: "CEO, Quillpoint", tone: "bg-teal-300 text-teal-950" },
      { quote: "We moved four entities over in a weekend and the intercompany entries just lined up.", name: "Rafael Montoya", role: "Group Accountant, Alder and Pine", tone: "bg-amber-300 text-amber-950", stat: "4 entities" },
      { quote: "The mobile approvals alone gave me back my Thursday evenings.", name: "Greta Sundberg", role: "Managing Partner, Nordlys", tone: "bg-lime-300 text-lime-950" },
      { quote: "I have recommended it to every founder I mentor. Two of them emailed to thank me, which never happens.", name: "Wendell Achebe", role: "Advisor, Seedbed Collective", tone: "bg-emerald-300 text-emerald-950" },
    ],
  },
];

const css = `
@keyframes three-column-testimonial-wall-with-vertical-scroll-up { to { transform: translate3d(0, -50%, 0); } }
@keyframes three-column-testimonial-wall-with-vertical-scroll-in { from { opacity: 0; transform: translate3d(0, 28px, 0); } to { opacity: 1; transform: none; } }
@keyframes three-column-testimonial-wall-with-vertical-scroll-glow { 0%, 100% { opacity: 0.55; transform: translate3d(0, 0, 0) scale(1); } 50% { opacity: 0.9; transform: translate3d(4%, -3%, 0) scale(1.12); } }
.three-column-testimonial-wall-with-vertical-scroll-track { animation: three-column-testimonial-wall-with-vertical-scroll-up linear infinite; will-change: transform; }
.three-column-testimonial-wall-with-vertical-scroll-col:hover .three-column-testimonial-wall-with-vertical-scroll-track,
.three-column-testimonial-wall-with-vertical-scroll-col:focus-within .three-column-testimonial-wall-with-vertical-scroll-track { animation-play-state: paused !important; }
.three-column-testimonial-wall-with-vertical-scroll-in { animation: three-column-testimonial-wall-with-vertical-scroll-in 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) both; }
.three-column-testimonial-wall-with-vertical-scroll-glow { animation: three-column-testimonial-wall-with-vertical-scroll-glow 9s ease-in-out infinite; }
.three-column-testimonial-wall-with-vertical-scroll-mask {
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 16%, #000 84%, transparent);
}
@media (prefers-reduced-motion: reduce) {
  .three-column-testimonial-wall-with-vertical-scroll-track,
  .three-column-testimonial-wall-with-vertical-scroll-in,
  .three-column-testimonial-wall-with-vertical-scroll-glow { animation: none; }
}
`;

function Card({ r }: { r: Review }) {
  const initials = r.name.split(" ").map((w) => w[0]).join("");
  return (
    <figure className="group mb-4 rounded-2xl border border-white/10 bg-white/[0.04] p-6 transition duration-300 hover:border-lime-300/50 hover:bg-white/[0.08]">
      {r.stat && (
        <p className="mb-4 inline-flex items-center gap-2 rounded-full bg-lime-300/10 px-3 py-1 text-xs font-semibold text-lime-300">
          <span aria-hidden="true" className="h-1.5 w-1.5 rounded-full bg-lime-300" />
          {r.stat}
        </p>
      )}
      <blockquote className="text-[15px] leading-relaxed text-emerald-50/90">{r.quote}</blockquote>
      <figcaption className="mt-5 flex items-center gap-3">
        <span aria-hidden="true" className={`flex h-9 w-9 flex-none items-center justify-center rounded-full text-xs font-bold ${r.tone}`}>{initials}</span>
        <span className="min-w-0">
          <span className="block truncate text-sm font-semibold text-white">{r.name}</span>
          <span className="block text-xs leading-snug text-emerald-100/60">{r.role}</span>
        </span>
      </figcaption>
    </figure>
  );
}

export default function ThreeColumnTestimonialWallWithVerticalScroll() {
  const section = useRef<HTMLElement>(null);
  const [onScreen, setOnScreen] = useState(true);
  const [paused, setPaused] = useState(false);

  // Stop the columns when the section is scrolled away.
  useEffect(() => {
    const el = section.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([entry]) => setOnScreen(entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  const state = onScreen && !paused ? "running" : "paused";

  return (
    <section ref={section} className="relative overflow-hidden bg-[#05130e] px-6 py-16 text-white sm:py-20">
      <style>{css}</style>
      <div aria-hidden="true" className="three-column-testimonial-wall-with-vertical-scroll-glow pointer-events-none absolute -left-40 top-10 h-[520px] w-[520px] rounded-full bg-emerald-500/20 blur-3xl" />
      <div className="relative mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-12">
        <div className="three-column-testimonial-wall-with-vertical-scroll-in lg:col-span-4">
          <p className="text-xs font-semibold uppercase tracking-[0.2em] text-lime-300">Customer stories</p>
          <h2 className="mt-4 text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl">
            Month end, <span className="text-lime-300">without the dread.</span>
          </h2>
          <p className="mt-5 max-w-md text-base leading-relaxed text-emerald-100/70">
            3,100 finance teams run their close on Ledgerly. Here is what they told us, unedited.
          </p>
          <dl className="mt-8 flex gap-10">
            <div>
              <dt className="text-xs text-emerald-100/60">Average rating</dt>
              <dd className="mt-1 text-3xl font-semibold tracking-tight">4.9<span className="text-base font-medium text-emerald-100/50"> / 5</span></dd>
            </div>
            <div>
              <dt className="text-xs text-emerald-100/60">Would recommend</dt>
              <dd className="mt-1 text-3xl font-semibold tracking-tight">97%</dd>
            </div>
          </dl>
          <div className="mt-9 flex flex-wrap items-center gap-3">
            <a href="#" className="inline-flex h-11 items-center rounded-full bg-lime-300 px-6 text-sm font-semibold text-lime-950 transition hover:bg-lime-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300 focus-visible:ring-offset-2 focus-visible:ring-offset-[#05130e]">
              Start a free close
            </a>
            <button
              type="button"
              aria-pressed={paused}
              onClick={() => setPaused((p) => !p)}
              className="inline-flex h-11 items-center gap-2 rounded-full border border-white/15 px-5 text-sm font-medium text-emerald-50 transition hover:border-white/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-lime-300"
            >
              <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4" fill="currentColor">
                {paused ? <path d="M6 4.5v11l9-5.5Z" /> : <path d="M5.5 4h3v12h-3zM11.5 4h3v12h-3z" />}
              </svg>
              {paused ? "Play wall" : "Pause wall"}
            </button>
          </div>
        </div>

        <div className="three-column-testimonial-wall-with-vertical-scroll-in three-column-testimonial-wall-with-vertical-scroll-mask grid h-[560px] grid-cols-1 gap-4 overflow-hidden sm:grid-cols-2 lg:col-span-8 lg:h-[640px] lg:grid-cols-3" style={{ animationDelay: "0.15s" }}>
          {columns.map((col, i) => (
            <div key={i} className={`three-column-testimonial-wall-with-vertical-scroll-col ${col.show}`}>
              <div
                className="three-column-testimonial-wall-with-vertical-scroll-track"
                style={{ animationDuration: `${col.seconds}s`, animationDirection: col.reverse ? "reverse" : "normal", animationPlayState: state }}
              >
                {[0, 1].map((copy) => (
                  <div key={copy} aria-hidden={copy === 1}>
                    {col.reviews.map((r) => (
                      <Card key={r.name} r={r} />
                    ))}
                  </div>
                ))}
              </div>
            </div>
          ))}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/three-column-testimonial-wall-with-vertical-scroll.json

When to use it

How to customize it

Accessibility

Questions

How does the vertical loop work?

Each column holds two identical stacks of cards and a keyframe moves the track up by exactly 50 percent of its height. At that point the second stack sits where the first one began, so the loop restarts without a jump. The gap between cards is a bottom margin on every card, which keeps both halves exactly the same height.

How does pause on hover work?

It is one CSS rule: when a column is hovered, its track gets `animation-play-state: paused`. Nothing is measured and no script runs, so the card stops exactly where it is and carries on from the same spot.

Why do the columns move at different speeds?

Equal speeds make the wall look like one sliding sheet. Different durations, and one reversed column, create a parallax feel and mean the same three cards never line up twice.

Can I add more reviews?

Yes. Add objects to a column's `reviews` array. Longer columns need a larger `seconds` value to keep the same reading pace, roughly ten seconds per card.

Does it use framer-motion?

No. It needs only React and Tailwind. The movement, the entrance and the glow are CSS keyframes in the component's own style element.

Related: Testimonial Cards with Star Ratings and Initials Avatars, Large Quote Testimonial with Author and Role, Dual Row Logo Marquee with Edge Fade.

More marquees or all free UI components. Start for free to have a full site built around it.

More in Marquees

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap