Testimonial Wall

Columns of quotes drifting past each other at different speeds, faded at both ends.

Loading preview

Installation

npx shadcn@latest add @loomui/testimonial-wall

Usage

import { TestimonialWall } from "@/components/ui/testimonial-wall"
<TestimonialWall columns={3}>
  {quotes.map((quote) => (
    <figure key={quote.name} className="bg-card rounded-xl border p-4">
      <blockquote>{quote.body}</blockquote>
    </figure>
  ))}
</TestimonialWall>

Hand it a flat list of cards. They are dealt round robin across the columns, so a wall of uneven cards still balances out rather than leaving one column short.

Why it reads as depth

Every other column runs the other way, and each column gets its own duration, so no two neighbouring cards ever travel together. Cards moving in lockstep read as one sliding sheet. Cards moving against each other read as a wall with things happening on it.

<TestimonialWall duration={36} speeds={[1, 1.4, 0.85]} />

duration is the seconds for one pass of the first column. speeds multiplies it per column when you want to set the drift yourself instead of taking the default spread.

Each column is two copies of its cards travelling exactly one copy plus one gap, which is what makes the loop seamless. The whole thing is transform on a handful of elements, so the cost does not grow with the number of quotes.

Height and edges

The wall clips to its own height, 32rem by default, and fades top and bottom so cards enter and leave instead of being cut. Override either:

<TestimonialWall className="h-[40rem]" fade="20%" />

The fade is sampled off a smoothstep curve rather than a plain two-stop gradient. A linear alpha ramp meets full opacity at a point the eye reads as a hard line.

On narrow screens the extra columns are dropped rather than squeezed: the second appears at sm, the third at lg.

Under prefers-reduced-motion the whole wall is frozen at its start, rather than stopping mid-travel with half a card cut off.

Props

PropTypeDefaultDescription
columnsnumber3Columns at the widest breakpoint.
durationnumber44Seconds for one pass of the first column.
speedsnumber[]nonePer-column multipliers on duration.
gapstring"1rem"Gap between cards and between columns.
pauseOnHoverbooleantrueStop every column while the pointer is over it.
pausedbooleanfalseRender the wall static.
fadeboolean | stringtrueFade both ends. A length sizes the fade.
classNamestringnoneMerged onto the wall. Height goes here.

Any other <div> prop is forwarded.

Accessibility

Only the first copy of each column is read. The duplicate that makes the loop seamless is aria-hidden, so no quote is announced twice. pauseOnHover is on by default, which is what makes a quote readable at all without chasing it.