Testimonials
Testimonials Marquee
Review cards in two rows drifting in opposite directions, a snapping carousel on smaller screens.
Layout and motion from the Findit template — see it in context"use client";
import * as React from "react";
import { Marquee } from "@/components/ui/marquee";
import { cn } from "@/lib/utils";
/*
* Testimonials Marquee — a centered heading above review cards that never stop: two rows drifting in opposite
* directions from 1024px, a snapping carousel below that.
* Source: marcframe/findit → components/sections/TestimonialsSection.tsx (+ design-system Ticker, Carousel,
* TestimonialCard).
*
* Layout kept from Framer: from 1024px the reviews split into two rows, the first moving left and the second right at
* 20px/s, 20px apart, their edges fading out over 10% (marquee; still with reduced motion). Below 1024px one carousel
* of all of them scrolls sideways and snaps each card to the center, its edges fading too; round arrows sit 20px in
* from each side and hide at their end. Cards are 300 × 325: stars, title and quote at the top, photo and name at the
* bottom. The band sits on the muted surface.
* Both rows are read once by screen readers (marquee hides its copies) inside a labelled group; the carousel is a
* labelled region with named arrows. Stars use the primary color (Findit's yellow).
*/
export type Review = { name: string; title: string; quote: string; rating?: number; photo: { src: string; alt?: string } };
export type TestimonialsMarqueeProps = {
/** Small line above the heading. */
label?: string;
title?: string;
lead?: string;
reviews?: Review[];
/** Pixels per second for the rows. */
speed?: number;
className?: string;
};
const face = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&crop=faces&w=96&h=96&q=80`;
const defaults = {
label: "Testimonials",
title: "What our clients say",
lead: "Real stories from the people who bought, sold and rented with us.",
reviews: [
{ name: "Anna Vogel", title: "A seamless journey", quote: "Smooth and professional from the first viewing to the keys. They answered every question, even the silly ones.", photo: { src: face("photo-1494790108377-be9c29b29330") } },
{ name: "James Holt", title: "Sold above asking", quote: "Our flat sold in three weeks for more than we hoped. The photos and the pricing advice made the difference.", photo: { src: face("photo-1507003211169-0a1dd7228f2d") } },
{ name: "Lena Brooks", title: "Found the right area", quote: "We didn't know the city at all. They showed us three neighbourhoods and we fell for the second one.", photo: { src: face("photo-1438761681033-6461ffad8d80") } },
{ name: "Omar Reyes", title: "Clear and honest", quote: "No pressure, no hidden fees, just straight answers. Exactly what you want when you buy your first home.", photo: { src: face("photo-1500648767791-00dcc994a43e") } },
{ name: "Mia Lawson", title: "Renting made easy", quote: "Viewings on a Sunday, contract signed on Tuesday. Renting has never been this quick for us.", photo: { src: face("photo-1534528741775-53994a69daeb") } },
{ name: "Ethan Cole", title: "Great negotiators", quote: "They got the seller to fix the roof before we moved in. That alone paid for their help.", photo: { src: face("photo-1506794778202-cad84cf45f1d") } },
{ name: "Grace Palmer", title: "Felt looked after", quote: "Moving with two kids is chaos. Having one person to call for everything kept us sane.", photo: { src: face("photo-1517841905240-472988babdf9") } },
{ name: "Noah Fischer", title: "Worth every step", quote: "From the valuation to the handover, every step was explained before it happened. No surprises.", photo: { src: face("photo-1522075469751-3a6694fb2f61") } },
{ name: "Chloe Martin", title: "Our dream flat", quote: "They remembered the one thing we cared about most — light — and only showed us bright homes.", photo: { src: face("photo-1531746020798-e6953c6e8e04") } },
{ name: "Liam Turner", title: "Investor friendly", quote: "Solid numbers on rent and yield for every option. We bought two units through them in a year.", photo: { src: face("photo-1552058544-f2b08422138a") } },
{ name: "Sara Nolan", title: "Quick and friendly", quote: "Friendly, fast and always on time. We recommend them to everyone who asks.", photo: { src: face("photo-1573496359142-b8d87734a5a2") } },
{ name: "Ben Walsh", title: "Smooth relocation", quote: "We moved from abroad and bought without flying in once. The video tours were spot on.", photo: { src: face("photo-1560250097-0b93528c311a") } },
] as Review[],
};
function Card({ review }: { review: Review }) {
const rating = review.rating ?? 5;
return (
<figure className="flex h-81 w-75 shrink-0 flex-col justify-between rounded-xl bg-card p-5 text-card-foreground">
<div className="flex flex-col gap-5">
<span role="img" aria-label={`Rated ${rating} out of 5`} className="flex gap-1 text-primary">
{Array.from({ length: rating }, (_, i) => (
<svg key={i} viewBox="0 0 16 16" className="size-3.5 fill-current" aria-hidden>
<path d="M8 1.2l2 4.3 4.7.5-3.5 3.2 1 4.6L8 11.5l-4.2 2.3 1-4.6L1.3 6l4.7-.5z" />
</svg>
))}
</span>
<blockquote className="flex flex-col gap-2.5">
<p className="font-medium">{review.title}</p>
<p className="text-sm text-muted-foreground">{review.quote}</p>
</blockquote>
</div>
<figcaption className="flex items-center gap-2.5 text-sm">
<img src={review.photo.src} alt={review.photo.alt ?? ""} className="size-10.5 shrink-0 rounded-full bg-muted object-cover" />
{review.name}
</figcaption>
</figure>
);
}
function Carousel({ reviews, label, className }: { reviews: Review[]; label: string; className?: string }) {
const track = React.useRef<HTMLUListElement>(null);
const [edges, setEdges] = React.useState({ start: true, end: false });
const update = React.useCallback(() => {
const el = track.current;
if (!el) return;
setEdges({ start: el.scrollLeft <= 1, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 1 });
}, []);
React.useEffect(() => {
const el = track.current;
if (!el) return;
const resize = new ResizeObserver(update);
resize.observe(el);
return () => resize.disconnect();
}, [update]);
function move(direction: 1 | -1) {
const el = track.current;
const item = el?.firstElementChild as HTMLElement | null;
if (!el || !item) return;
const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
el.scrollBy({ left: direction * (item.offsetWidth + 20), behavior: smooth ? "smooth" : "auto" });
}
const arrow = (direction: 1 | -1, hidden: boolean) => (
<button
type="button"
onClick={() => move(direction)}
aria-label={direction === 1 ? "Next review" : "Previous review"}
tabIndex={hidden ? -1 : undefined}
className={cn(
"absolute top-1/2 grid size-10 -translate-y-1/2 cursor-pointer place-items-center rounded-full border bg-background/80 text-foreground shadow-sm backdrop-blur transition-opacity duration-200 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
direction === 1 ? "right-5" : "left-5",
hidden && "pointer-events-none opacity-0",
)}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-4" aria-hidden>
<path d={direction === 1 ? "M6 3.5L10.5 8 6 12.5" : "M10 3.5L5.5 8l4.5 4.5"} />
</svg>
</button>
);
return (
<div role="region" aria-roledescription="carousel" aria-label={label} className={cn("relative", className)}>
<ul
ref={track}
onScroll={update}
className="flex snap-x snap-mandatory gap-5 overflow-x-auto [mask-image:linear-gradient(90deg,transparent_0%,black_10%,black_90%,transparent_100%)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{reviews.map((review) => (
<li key={review.name} className="shrink-0 snap-center">
<Card review={review} />
</li>
))}
</ul>
{arrow(-1, edges.start)}
{arrow(1, edges.end)}
</div>
);
}
export function TestimonialsMarquee({
label = defaults.label,
title = defaults.title,
lead = defaults.lead,
reviews = defaults.reviews,
speed = 20,
className,
}: TestimonialsMarqueeProps) {
const half = Math.ceil(reviews.length / 2);
const rows = [reviews.slice(0, half), reviews.slice(half)];
return (
<section className={cn("overflow-hidden bg-muted py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col items-center gap-5 text-center">
<div className="flex flex-col items-center gap-2.5">
{label && <p className="text-sm font-medium text-foreground/65">{label}</p>}
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
</div>
{lead && <p className="max-w-md text-pretty text-foreground/65">{lead}</p>}
</div>
<div role="group" aria-label={title} className="hidden flex-col gap-5 lg:flex">
{rows.map((row, i) => (
<div key={i}>
<Marquee speed={speed} gap={20} repeat={1} fade direction={i === 0 ? "left" : "right"}>
{row.map((review) => (
<Card key={review.name} review={review} />
))}
</Marquee>
</div>
))}
</div>
<Carousel reviews={reviews} label={title} className="lg:hidden" />
</div>
</section>
);
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/testimonials-marquee.json1. Add the Framebrick UI components it uses (they bring Motion).
npx shadcn@latest add https://framebrick.co/r/marquee.json2. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json3. Copy the block into
components/testimonials-marquee.tsx."use client"; import * as React from "react"; import { Marquee } from "@/components/ui/marquee"; import { cn } from "@/lib/utils"; /* * Testimonials Marquee — a centered heading above review cards that never stop: two rows drifting in opposite * directions from 1024px, a snapping carousel below that. * Source: marcframe/findit → components/sections/TestimonialsSection.tsx (+ design-system Ticker, Carousel, * TestimonialCard). * * Layout kept from Framer: from 1024px the reviews split into two rows, the first moving left and the second right at * 20px/s, 20px apart, their edges fading out over 10% (marquee; still with reduced motion). Below 1024px one carousel * of all of them scrolls sideways and snaps each card to the center, its edges fading too; round arrows sit 20px in * from each side and hide at their end. Cards are 300 × 325: stars, title and quote at the top, photo and name at the * bottom. The band sits on the muted surface. * Both rows are read once by screen readers (marquee hides its copies) inside a labelled group; the carousel is a * labelled region with named arrows. Stars use the primary color (Findit's yellow). */ export type Review = { name: string; title: string; quote: string; rating?: number; photo: { src: string; alt?: string } }; export type TestimonialsMarqueeProps = { /** Small line above the heading. */ label?: string; title?: string; lead?: string; reviews?: Review[]; /** Pixels per second for the rows. */ speed?: number; className?: string; }; const face = (id: string) => `https://images.unsplash.com/${id}?auto=format&fit=crop&crop=faces&w=96&h=96&q=80`; const defaults = { label: "Testimonials", title: "What our clients say", lead: "Real stories from the people who bought, sold and rented with us.", reviews: [ { name: "Anna Vogel", title: "A seamless journey", quote: "Smooth and professional from the first viewing to the keys. They answered every question, even the silly ones.", photo: { src: face("photo-1494790108377-be9c29b29330") } }, { name: "James Holt", title: "Sold above asking", quote: "Our flat sold in three weeks for more than we hoped. The photos and the pricing advice made the difference.", photo: { src: face("photo-1507003211169-0a1dd7228f2d") } }, { name: "Lena Brooks", title: "Found the right area", quote: "We didn't know the city at all. They showed us three neighbourhoods and we fell for the second one.", photo: { src: face("photo-1438761681033-6461ffad8d80") } }, { name: "Omar Reyes", title: "Clear and honest", quote: "No pressure, no hidden fees, just straight answers. Exactly what you want when you buy your first home.", photo: { src: face("photo-1500648767791-00dcc994a43e") } }, { name: "Mia Lawson", title: "Renting made easy", quote: "Viewings on a Sunday, contract signed on Tuesday. Renting has never been this quick for us.", photo: { src: face("photo-1534528741775-53994a69daeb") } }, { name: "Ethan Cole", title: "Great negotiators", quote: "They got the seller to fix the roof before we moved in. That alone paid for their help.", photo: { src: face("photo-1506794778202-cad84cf45f1d") } }, { name: "Grace Palmer", title: "Felt looked after", quote: "Moving with two kids is chaos. Having one person to call for everything kept us sane.", photo: { src: face("photo-1517841905240-472988babdf9") } }, { name: "Noah Fischer", title: "Worth every step", quote: "From the valuation to the handover, every step was explained before it happened. No surprises.", photo: { src: face("photo-1522075469751-3a6694fb2f61") } }, { name: "Chloe Martin", title: "Our dream flat", quote: "They remembered the one thing we cared about most — light — and only showed us bright homes.", photo: { src: face("photo-1531746020798-e6953c6e8e04") } }, { name: "Liam Turner", title: "Investor friendly", quote: "Solid numbers on rent and yield for every option. We bought two units through them in a year.", photo: { src: face("photo-1552058544-f2b08422138a") } }, { name: "Sara Nolan", title: "Quick and friendly", quote: "Friendly, fast and always on time. We recommend them to everyone who asks.", photo: { src: face("photo-1573496359142-b8d87734a5a2") } }, { name: "Ben Walsh", title: "Smooth relocation", quote: "We moved from abroad and bought without flying in once. The video tours were spot on.", photo: { src: face("photo-1560250097-0b93528c311a") } }, ] as Review[], }; function Card({ review }: { review: Review }) { const rating = review.rating ?? 5; return ( <figure className="flex h-81 w-75 shrink-0 flex-col justify-between rounded-xl bg-card p-5 text-card-foreground"> <div className="flex flex-col gap-5"> <span role="img" aria-label={`Rated ${rating} out of 5`} className="flex gap-1 text-primary"> {Array.from({ length: rating }, (_, i) => ( <svg key={i} viewBox="0 0 16 16" className="size-3.5 fill-current" aria-hidden> <path d="M8 1.2l2 4.3 4.7.5-3.5 3.2 1 4.6L8 11.5l-4.2 2.3 1-4.6L1.3 6l4.7-.5z" /> </svg> ))} </span> <blockquote className="flex flex-col gap-2.5"> <p className="font-medium">{review.title}</p> <p className="text-sm text-muted-foreground">{review.quote}</p> </blockquote> </div> <figcaption className="flex items-center gap-2.5 text-sm"> <img src={review.photo.src} alt={review.photo.alt ?? ""} className="size-10.5 shrink-0 rounded-full bg-muted object-cover" /> {review.name} </figcaption> </figure> ); } function Carousel({ reviews, label, className }: { reviews: Review[]; label: string; className?: string }) { const track = React.useRef<HTMLUListElement>(null); const [edges, setEdges] = React.useState({ start: true, end: false }); const update = React.useCallback(() => { const el = track.current; if (!el) return; setEdges({ start: el.scrollLeft <= 1, end: el.scrollLeft + el.clientWidth >= el.scrollWidth - 1 }); }, []); React.useEffect(() => { const el = track.current; if (!el) return; const resize = new ResizeObserver(update); resize.observe(el); return () => resize.disconnect(); }, [update]); function move(direction: 1 | -1) { const el = track.current; const item = el?.firstElementChild as HTMLElement | null; if (!el || !item) return; const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches; el.scrollBy({ left: direction * (item.offsetWidth + 20), behavior: smooth ? "smooth" : "auto" }); } const arrow = (direction: 1 | -1, hidden: boolean) => ( <button type="button" onClick={() => move(direction)} aria-label={direction === 1 ? "Next review" : "Previous review"} tabIndex={hidden ? -1 : undefined} className={cn( "absolute top-1/2 grid size-10 -translate-y-1/2 cursor-pointer place-items-center rounded-full border bg-background/80 text-foreground shadow-sm backdrop-blur transition-opacity duration-200 hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none", direction === 1 ? "right-5" : "left-5", hidden && "pointer-events-none opacity-0", )} > <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round" className="size-4" aria-hidden> <path d={direction === 1 ? "M6 3.5L10.5 8 6 12.5" : "M10 3.5L5.5 8l4.5 4.5"} /> </svg> </button> ); return ( <div role="region" aria-roledescription="carousel" aria-label={label} className={cn("relative", className)}> <ul ref={track} onScroll={update} className="flex snap-x snap-mandatory gap-5 overflow-x-auto [mask-image:linear-gradient(90deg,transparent_0%,black_10%,black_90%,transparent_100%)] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" > {reviews.map((review) => ( <li key={review.name} className="shrink-0 snap-center"> <Card review={review} /> </li> ))} </ul> {arrow(-1, edges.start)} {arrow(1, edges.end)} </div> ); } export function TestimonialsMarquee({ label = defaults.label, title = defaults.title, lead = defaults.lead, reviews = defaults.reviews, speed = 20, className, }: TestimonialsMarqueeProps) { const half = Math.ceil(reviews.length / 2); const rows = [reviews.slice(0, half), reviews.slice(half)]; return ( <section className={cn("overflow-hidden bg-muted py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col items-center gap-5 text-center"> <div className="flex flex-col items-center gap-2.5"> {label && <p className="text-sm font-medium text-foreground/65">{label}</p>} <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> </div> {lead && <p className="max-w-md text-pretty text-foreground/65">{lead}</p>} </div> <div role="group" aria-label={title} className="hidden flex-col gap-5 lg:flex"> {rows.map((row, i) => ( <div key={i}> <Marquee speed={speed} gap={20} repeat={1} fade direction={i === 0 ? "left" : "right"}> {row.map((review) => ( <Card key={review.name} review={review} /> ))} </Marquee> </div> ))} </div> <Carousel reviews={reviews} label={title} className="lg:hidden" /> </div> </section> ); }
Usage
import { TestimonialsMarquee } from "@/components/testimonials-marquee";
<TestimonialsMarquee
reviews={[
{ name: "Anna Vogel", title: "A seamless journey", quote: "Smooth and professional.", photo: { src: "/clients/anna.jpg" } },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Testimonials | Small muted line above the heading. |
| title / lead | string | — | Centered heading (h2) and lead; the title also labels the rows and the carousel. |
| reviews | { name, title, quote, rating?, photo: { src, alt? } }[] | 12 reviews | Split in two rows from 1024px; all in the carousel below. rating defaults to 5. |
| speed | number | 20 | Pixels per second for the rows. |
| className | string | — | Classes for the <section>. |
- From 1024px two rows move in opposite directions at 20px/s with faded edges (marquee); they stand still with reduced motion. Give each row enough cards to fill the width (six at 1280px).
- Below 1024px a carousel snaps each card to the center; arrows 20px in from each side hide at their end and move one card.
- Stars use the primary color. The band sits on the muted surface.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called TestimonialsMarquee (Next.js). - <section className="overflow-hidden bg-muted py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Centered title: a small muted label, an h2 (font-heading text-h2 semibold), a muted lead up to max-w-md. - Card (300 × 324, rounded-xl bg-card p-5, content spread top to bottom): five 14px stars in text-primary (role="img", "Rated 5 out of 5"), then the title (font-medium) and quote (text-sm muted) in a blockquote; at the bottom a 42px round photo and the name (text-sm). - From 1024px: split the reviews into two rows that scroll forever — the first to the left, the second to the right — at 20px per second, 20px apart, with edges masked to transparent over 10%; still with prefers-reduced-motion; duplicated copies aria-hidden and inert. - Below 1024px: a horizontal scroll-snap list (snap-center, gap-5, hidden scrollbar, the same edge mask) with two 40px round arrow buttons 20px in from each side that scroll by one card and fade out at their end. - Only shadcn variables and block tokens (secondary text on bg-muted is text-foreground/65: muted-foreground falls under 4.5:1 there).











