Section
Testimonials
Five review cards tapering out from a large centre, flung in from the right one after another — each overrunning its slot by a different amount before settling back.
Juno Park
Product designer
Dropped three of these into a pitch build and nobody asked which library they came from.
Ravi Sharma
Frontend lead
Zero runtime surprises, which is the entire point. The code on the page is the code that ships.
Aria Meyer
Design engineer
The motion defaults are genuinely tasteful. I changed two constants and it matched our brand.
Kel Danvers
Founder
We replaced a whole dependency with four files we can actually read and edit.
Mira Kaur
Creative director
It looks expensive without looking busy, and it still works on a phone.
Scroll inside the frame to trigger the entrance
import { useRef } from "react";
import { Testimonials, type Testimonial } from "@/components/ui/testimonials";
const SCREEN = 520;
// Left to right, so the middle entry is the one that lands centre stage.
const items: Testimonial[] = [
{
name: "Juno Park",
role: "Product designer",
quote: "Dropped three of these into a pitch build and nobody asked which library they came from.",
},
{
name: "Ravi Sharma",
role: "Frontend lead",
quote: "Zero runtime surprises, which is the entire point. The code on the page is the code that ships.",
},
{
name: "Aria Meyer",
role: "Design engineer",
quote: "The motion defaults are genuinely tasteful. I changed two constants and it matched our brand.",
},
{
name: "Kel Danvers",
role: "Founder",
quote: "We replaced a whole dependency with four files we can actually read and edit.",
},
{
name: "Mira Kaur",
role: "Creative director",
quote: "It looks expensive without looking busy, and it still works on a phone.",
},
];
export default function TestimonialsDemo({ fullscreen }: { fullscreen?: boolean }) {
const scroller = useRef<HTMLDivElement>(null);
if (fullscreen) {
return <Testimonials items={items} height="100dvh" />;
}
return (
<div className="w-full min-w-0 space-y-3">
<div
ref={scroller}
className="scrollbar-thin w-full min-w-0 overflow-y-auto overscroll-contain rounded-card border border-line"
style={{ height: SCREEN }}
>
{/* Room above, so the section can scroll into its own trigger. */}
<div style={{ height: SCREEN * 0.6 }} />
<Testimonials
items={items}
height={`${SCREEN}px`}
container={scroller}
/>
<div style={{ height: SCREEN * 0.4 }} />
</div>
<p className="text-center text-xs text-faint">
Scroll inside the frame to trigger the entrance
</p>
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/testimonials.tsx "use client"; import gsap from "gsap"; import { ScrollTrigger } from "gsap/ScrollTrigger"; import { useEffect, useRef, useState, type RefObject } from "react"; import { cn } from "@/lib/utils"; export type Testimonial = { name: string; role?: string; quote: string; /** Out of five. */ rating?: number; /** Card tint. Falls back to a rotating set. */ color?: string; }; type TestimonialsProps = { /** Rendered left to right. The middle one is the largest. */ items: Testimonial[]; /** Height of the stage. */ height?: string; /** The scrollable ancestor, when this lives inside one instead of the page. */ container?: RefObject<HTMLElement | null>; className?: string; }; /** Card widths by distance from the middle. Height is 16:9 of the width. */ const WIDTHS = [252, 224, 198]; const RATIO = 16 / 9; const GAP = 24; /** Slightly different tints, so no two neighbours match. */ const TINTS = ["#e9e3f6", "#dfe9f3", "#f4e8de", "#e1f0e6", "#f4e5ef"]; /* --- The entrance, three seconds end to end ------------------------------- */ /** Every card covers the same distance in the same time, so all move alike. */ const ENTER = 1400; const STAGGER = 0.2; const IN = 0.7; const BACK = 1.15; const SETTLE = 0.35; /** * How far past its slot a card carries, keyed by its distance from the middle * and which side it is on. The middle card lands clean; the two on the right * run far enough to bury it before recovering. */ const OVERSHOOT = [12, 18, 0, 250, 320]; /** Only the two that overrun far enough to need recovering from tilt. */ const TILTS = [false, false, false, true, true]; export function Testimonials({ items, height = "100dvh", container, className, }: TestimonialsProps) { const rootRef = useRef<HTMLDivElement>(null); const stageRef = useRef<HTMLDivElement>(null); const cardRefs = useRef<(HTMLElement | null)[]>([]); const [fit, setFit] = useState(1); const middle = (items.length - 1) / 2; // Widths taper from the middle out, then the row is laid out left to right. const sizes = items.map((_, index) => { const away = Math.min(WIDTHS.length - 1, Math.round(Math.abs(index - middle))); const width = WIDTHS[away]!; return { width, height: width * RATIO }; }); const rowWidth = sizes.reduce((total, size) => total + size.width, 0) + GAP * (items.length - 1); let cursor = 0; const lefts = sizes.map((size) => { const left = cursor; cursor += size.width + GAP; return left; }); useEffect(() => { const stage = stageRef.current; if (!stage) return; const measure = () => { setFit(Math.min(1, (stage.clientWidth - 48) / rowWidth)); ScrollTrigger.refresh(); }; measure(); const observer = new ResizeObserver(measure); observer.observe(stage); return () => observer.disconnect(); }, [rowWidth]); useEffect(() => { gsap.registerPlugin(ScrollTrigger); const context = gsap.context(() => { const nodes = cardRefs.current; gsap.set(nodes.filter(Boolean), { x: ENTER, rotate: 0 }); const timeline = gsap.timeline({ paused: true, defaults: { overwrite: "auto" }, }); items.forEach((_, index) => { const node = nodes[index]; if (!node) return; const at = index * STAGGER; const past = OVERSHOOT[Math.min(OVERSHOOT.length - 1, index)] ?? 0; // In from the right, carrying past the slot. timeline.to(node, { x: -past, duration: IN, ease: "power4.out" }, at); // Then settling back onto it. timeline.to(node, { x: 0, duration: BACK, ease: "power2.out" }, at + IN); if (TILTS[Math.min(TILTS.length - 1, index)]) { // Leans into the return, kicks the other way as it arrives, levels off. timeline .to(node, { rotate: -15, duration: BACK * 0.45, ease: "power2.out" }, at + IN) .to( node, { rotate: 10, duration: BACK * 0.55, ease: "power2.inOut" }, at + IN + BACK * 0.45, ) .to(node, { rotate: 0, duration: SETTLE, ease: "power2.out" }, at + IN + BACK); } }); ScrollTrigger.create({ trigger: rootRef.current, scroller: container?.current ?? undefined, start: "top 75%", once: true, onEnter: () => timeline.play(0), }); }, rootRef); return () => context.revert(); }, [items, container]); return ( <section ref={rootRef} style={{ height }} className={cn("relative overflow-hidden bg-[#0f0f12] text-white", className)} > <div ref={stageRef} className="absolute inset-0 grid place-items-center"> <div className="relative" style={{ width: rowWidth, height: Math.max(...sizes.map((size) => size.height)), transform: `scale(${fit})`, }} > {items.map((item, index) => { const size = sizes[index]!; const rating = item.rating ?? 5; return ( <article key={item.name} ref={(node) => { cardRefs.current[index] = node; }} style={{ width: size.width, height: size.height, left: lefts[index], // Every card stands on the same line rather than floating. bottom: 0, backgroundColor: item.color ?? TINTS[index % TINTS.length], // Type scales with the card so the three sizes read alike. fontSize: size.width * 0.062, // Stacked left to right: the last card sits over all of them, // which is also what lets review 2 bury review 3 on arrival. zIndex: index + 1, }} className="absolute flex flex-col rounded-[1.1em] p-[1.15em] text-[#1b1b22] will-change-transform" > <div className="flex gap-[0.15em] text-[0.8em]" aria-hidden> {Array.from({ length: 5 }, (_, star) => ( <span key={star} className={star < rating ? "" : "opacity-25"}> ★ </span> ))} </div> <p className="mt-[0.7em] text-[0.95em] leading-tight font-semibold"> {item.name} </p> {item.role ? ( <p className="text-[0.75em] text-[#1b1b22]/55">{item.role}</p> ) : null} <p className="mt-[0.9em] text-[0.82em] leading-[1.45] text-[#1b1b22]/75"> {item.quote} </p> </article> ); })} </div> </div> </section> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| items | Testimonial[] | — | Rendered left to right. The middle one is the largest. |
| height | string | "100dvh" | Height of the stage. |
| container | RefObject<HTMLElement | null> | — | The scrollable ancestor, when the section lives inside one instead of the page. |