Skip to content
jswnthui

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

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
itemsTestimonial[]—Rendered left to right. The middle one is the largest.
heightstring"100dvh"Height of the stage.
containerRefObject<HTMLElement | null>—The scrollable ancestor, when the section lives inside one instead of the page.