"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 (
{item.quote}
{item.avatar ? ( ) : ( {item.name.charAt(0)} )} {item.name} {item.role && {item.role}}
); } function Column({ items, reverse, speed, tile }: { items: TestimonialFlowItem[]; reverse: boolean; speed: number; tile: string }) { const reduce = useReducedMotion(); const box = useRef(null); const belt = useRef(null); const run = useRef(null); const anim = useRef(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 (
glide(0)} onPointerLeave={() => glide(1)} className="h-full min-w-0 flex-1 overflow-hidden motion-reduce:overflow-y-auto" >
{Array.from({ length: copies }, (_, copy) => (
0 || undefined} className="grid" style={{ gap: GAP, paddingBottom: GAP }}> {items.map((item) => ( ))}
))}
); } /* 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(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 (
{title && (
{eyebrow &&

{eyebrow}

} {title} {subtitle &&

{subtitle}

}
)}
{Array.from({ length: count }, (_, c) => ( i % count === c)} reverse={c % 2 === 1} speed={speed} tile={tile} /> ))}
); }