Testimonial Wall
A three-column testimonial wall whose columns scroll in alternating directions, in either an auto infinite-scroll or a scroll-parallax style.
npx shadcn@latest add https://webberui.com/r/testimonial-wall.jsonPlayground
Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.
<TestimonialWall />
Installation
npx shadcn@latest add https://webberui.com/r/testimonial-wall.jsonOr, once registries are configured in components.json, install it as @webberui/testimonial-wall.
Usage
import { TestimonialWall, type Testimonial } from "@/components/ui/testimonial-wall";
const testimonials: Testimonial[] = [
{ quote: "Wired it up and it was running in five minutes.", name: "Aria Chen", title: "@aria" },
{ quote: "Ship it. It just works.", name: "Marcus Webb", title: "Loom" },
// …more testimonials
];
<TestimonialWall items={testimonials} height={480} />Adjacent columns scroll in opposite directions, and the data is distributed across the columns in round-robin order.
Scroll parallax style
variant="scroll" switches to a parallax driven by scroll position: adjacent columns move in opposite directions as the page scrolls. If the scrolling happens inside a nested overflow container, pass that container's ref to container (it can be omitted for window scrolling).
<TestimonialWall items={testimonials} variant="scroll" height={480} />Custom cards
<TestimonialWall
items={testimonials}
renderItem={(item) => (
<div className="rounded-lg border p-4">
<p>{item.quote}</p>
<span>{item.name}</span>
</div>
)}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | Testimonial[] | — | The testimonial data, distributed evenly across the columns in order |
columns | number | 3 | Number of columns; adjacent columns scroll in opposite directions |
variant | "marquee" | "scroll" | "marquee" | Auto infinite scroll, or a parallax driven by scroll position |
speed | number | 24 | marquee offset per second (px); only applies to marquee |
height | number | string | 480 | Visible height; anything beyond it is clipped |
gap | number | 16 | Spacing between cards (px) |
pauseOnHover | boolean | true | For marquee, whether hovering with the mouse or focusing with the keyboard pauses it |
renderItem | (item, index) => ReactNode | — | Custom card rendering |
label | string | "使用者評價" (Testimonials) | Accessible label for the whole region |
container | RefObject<HTMLElement> | — | Ref of the nested scroll container for the scroll variant |
className | string | — | Class for the outer region |
cardClassName | string | — | Class for the built-in card (has no effect with a custom renderItem) |
The Testimonial type: { quote: string; name: string; title?: string; avatar?: string }. When no avatar is provided, the card uses the first character of the name as a placeholder avatar.
How it works
marqueeadvances the offset manually withrequestAnimationFrameand loops two copies of the content seamlessly, so the speed is unaffected by how many cards each column holds.scrolltracks the section's progress through the viewport withuseScrolland applies the parallax within each column's scrollable overflow range, so no blank edges are ever exposed.- A gradient mask fades the top and bottom out, so cards entering and leaving dissolve naturally.
Accessibility
- When the user has "reduce motion" enabled at the system level, it falls back to a static version that shows every testimonial fully expanded, with no clipping and no animation.
- The whole thing is a
sectionlabelled witharia-label; the built-in cards use semanticfigure/blockquote/figcaption. - Because
marqueeduplicates the content to loop seamlessly, the duplicate is markedaria-hiddenso assistive technology does not announce it twice. - With
pauseOnHoverenabled, keyboard-focusing a link inside a card also pauses the scrolling, which makes reading and interacting easier.
Avatar Stack
A row of overlapping circular avatars; on hover or focus the avatar lifts and scales up, its neighbours move aside, and a name tooltip floats out.
Pricing Table
A pricing table with a monthly/yearly segmented switch; prices spring-tween on switch, the selected pill slides, the featured plan scales up and is highlighted, and yearly shows a discount badge.