Website Sections
TestimonialFlow
Columns of quotes drifting up and down with no seam. A column eases to a stop under the pointer. Under reduced motion they stand still and scroll by hand.
Download
- React
- Tailwind CSS v4
- motion
components/ui/testimonial-flow.tsx
"use client";
import { useEffect, useId, useRef, useState, type ElementType } from "react";
import { useReducedMotion } from "motion/react";
export type TestimonialFlowItem = {
quote: string;
name: string;
role?: string;
/** Face picture. Without it the first letter of the name shows. */
avatar?: string;
};
type TestimonialFlowProps = {
items: TestimonialFlowItem[];
title?: string;
/** Small line above the title. */
eyebrow?: string;
subtitle?: string;
/** Most columns on a wide screen. */
columns?: number;
/** Smallest column width in pixels. Fewer columns fit when narrow. */
minWidth?: number;
/** Pixels per second. */
speed?: number;
/** Card colour. */
tile?: string;
/** Sets the height of the moving part. */
boxClassName?: string;
/** h2 by default. */
headingAs?: ElementType;
/** Set padding and colours here. */
className?: string;
};
const GAP = 16;
const MASK = "linear-gradient(180deg, transparent, #000 9%, #000 91%, transparent)";
function Card({ item, tile }: { item: TestimonialFlowItem; tile: string }) {
return (
<figure className="rounded-2xl border border-current/10 p-5" style={{ backgroundColor: tile }}>
<blockquote className="text-sm leading-relaxed text-pretty opacity-90">{item.quote}</blockquote>
<figcaption className="mt-4 flex items-center gap-3">
{item.avatar ? (
<img src={item.avatar} alt="" className="size-9 rounded-full object-cover" />
) : (
<span aria-hidden className="grid size-9 place-items-center rounded-full bg-current/10 text-sm font-semibold">
{item.name.charAt(0)}
</span>
)}
<span className="min-w-0 text-sm">
<span className="block font-semibold">{item.name}</span>
{item.role && <span className="block truncate opacity-60">{item.role}</span>}
</span>
</figcaption>
</figure>
);
}
function Column({ items, reverse, speed, tile }: { items: TestimonialFlowItem[]; reverse: boolean; speed: number; tile: string }) {
const reduce = useReducedMotion();
const box = useRef<HTMLDivElement>(null);
const belt = useRef<HTMLDivElement>(null);
const run = useRef<HTMLDivElement>(null);
const anim = useRef<Animation | null>(null);
const frame = useRef(0);
const [copies, setCopies] = useState(2);
useEffect(() => {
if (!box.current || !run.current) return;
const watcher = new ResizeObserver(() => {
const height = run.current?.offsetHeight ?? 0;
if (!height) return;
setCopies(reduce ? 1 : Math.ceil((box.current?.offsetHeight ?? 0) / height) + 1);
anim.current?.effect?.updateTiming({ duration: (height / speed) * 1000 });
});
watcher.observe(box.current);
watcher.observe(run.current);
return () => watcher.disconnect();
}, [reduce, speed]);
useEffect(() => {
if (reduce || !box.current || !belt.current || !run.current) return;
const moving = belt.current.animate(
{ transform: ["translateY(0)", `translateY(${-100 / copies}%)`] },
{ duration: Math.max(1, (run.current.offsetHeight / speed) * 1000), iterations: Infinity, direction: reverse ? "reverse" : "normal" },
);
anim.current = moving;
const onScreen = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) moving.play();
else moving.pause();
});
onScreen.observe(box.current);
return () => {
cancelAnimationFrame(frame.current);
onScreen.disconnect();
moving.cancel();
anim.current = null;
};
}, [copies, reduce, reverse, speed]);
const glide = (to: number) => {
cancelAnimationFrame(frame.current);
const step = () => {
const moving = anim.current;
if (!moving) return;
const next = moving.playbackRate + (to - moving.playbackRate) * 0.14;
moving.playbackRate = Math.abs(to - next) < 0.01 ? to : next;
if (moving.playbackRate !== to) frame.current = requestAnimationFrame(step);
};
step();
};
return (
<div
ref={box}
tabIndex={copies === 1 ? 0 : undefined}
onPointerEnter={() => glide(0)}
onPointerLeave={() => glide(1)}
className="h-full min-w-0 flex-1 overflow-hidden motion-reduce:overflow-y-auto"
>
<div ref={belt}>
{Array.from({ length: copies }, (_, copy) => (
<div key={copy} ref={copy === 0 ? run : undefined} aria-hidden={copy > 0 || undefined} className="grid" style={{ gap: GAP, paddingBottom: GAP }}>
{items.map((item) => (
<Card key={item.name + item.quote} item={item} tile={tile} />
))}
</div>
))}
</div>
</div>
);
}
/* TestimonialFlow: columns of quotes drifting up and down at an even pace, with no seam. A column eases to a stop under the pointer. Under reduced motion they stand still and scroll by hand. */
export function TestimonialFlow({
items,
title,
eyebrow,
subtitle,
columns = 3,
minWidth = 260,
speed = 24,
tile = "#15122a",
boxClassName = "h-[34rem]",
headingAs: Heading = "h2",
className = "bg-[#0b0a14] px-5 py-16 text-white",
}: TestimonialFlowProps) {
const id = useId();
const wrap = useRef<HTMLDivElement>(null);
const [fit, setFit] = useState(columns);
const count = Math.max(1, Math.min(fit, columns, items.length));
useEffect(() => {
if (!wrap.current) return;
const watcher = new ResizeObserver(([entry]) => {
setFit(Math.floor((entry.contentRect.width + GAP) / (minWidth + GAP)));
});
watcher.observe(wrap.current);
return () => watcher.disconnect();
}, [minWidth]);
return (
<section aria-labelledby={title ? id : undefined} className={`@container ${className}`}>
{title && (
<header className="mx-auto mb-8 max-w-2xl text-center">
{eyebrow && <p className="mb-2 text-xs font-medium tracking-[0.2em] uppercase opacity-70">{eyebrow}</p>}
<Heading id={id} className="text-[clamp(1.5rem,6cqw,2.5rem)] leading-tight font-semibold tracking-tight text-balance">
{title}
</Heading>
{subtitle && <p className="mt-3 text-pretty opacity-70">{subtitle}</p>}
</header>
)}
<div ref={wrap} className={`mx-auto flex max-w-6xl ${boxClassName}`} style={{ gap: GAP, maskImage: MASK, WebkitMaskImage: MASK }}>
{Array.from({ length: count }, (_, c) => (
<Column key={c} items={items.filter((_, i) => i % count === c)} reverse={c % 2 === 1} speed={speed} tile={tile} />
))}
</div>
</section>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/testimonial-flow.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/testimonial-flow-demo.tsx
import { TestimonialFlow } from "@/components/ui/testimonial-flow"; const ITEMS = [ { quote: "We replaced three tools in one afternoon and nobody asked for the old ones back.", name: "Anika Rao", role: "Head of Ops, Tideline" }, { quote: "The first dashboard my whole team opens without being told to.", name: "Marcus Lee", role: "Founder, Emberly" }, { quote: "Setup took ten minutes. I kept waiting for the catch.", name: "Priya Nair", role: "Designer" }, { quote: "Clients stopped asking for status updates, because the page already tells them.", name: "Tomรกs Vega", role: "Studio lead, Mosslight" }, { quote: "Fast, calm and quiet. It does the job and stays out of the way.", name: "Lena Fischer", role: "Product manager" }, { quote: "Our invoices go out two days sooner now.", name: "Karthik S", role: "Accounts, Duskworks" }, { quote: "Support answered in minutes, and the person had read my whole message.", name: "Ama Mensah", role: "Freelancer" }, { quote: "I moved my whole shop over in a weekend.", name: "Noah Kim", role: "Shop owner" }, { quote: "The small details are why we stayed.", name: "Sara Haddad", role: "CTO, Meridian" }, ]; export default function TestimonialFlowExample() { return ( <div className="h-full overflow-y-auto"> <TestimonialFlow title="Loved by busy teams" items={ITEMS} headingAs="h3" boxClassName="h-[26rem]" className="min-h-full bg-[#0b0a14] p-4 text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | TestimonialFlowItem[] | โ | Each has quote and name, plus optional role and avatar. |
| title | string | โ | Heading above the columns. |
| eyebrow | string | โ | Small line above the title. |
| subtitle | string | โ | Line under the title. |
| columns | number | 3 | Most columns on a wide screen. |
| minWidth | number | 260 | Smallest column width in pixels. Fewer columns fit when narrow. |
| speed | number | 24 | Pixels per second. |
| tile | string | "#15122a" | Card colour. |
| boxClassName | string | "h-[34rem]" | Sets the height of the moving part. |
| headingAs | ElementType | "h2" | Heading tag. |
| className | string | "bg-[#0b0a14] px-5 py-16 text-white" | Set padding and colours here. |