Testimonials
Testimonials Mosaic
Five client quotes on dark and light cards of two widths around a short looping video.
Layout and motion from the Kalme template — see it in context"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
/*
* Testimonials Mosaic — a split title, then five client quotes on dark and light cards of two widths around a short
* looping video.
* Source: marcframe/kalme → components/sections/TestimonialsSection.tsx (+ design-system TestimonialCard, LoopVideo,
* SectionTitle split).
*
* Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px). Tiles,
* at least Framer's height (a longer quote grows its row), 20px apart: one column of 320px below 768px, two columns of
* 303px from 768px, and from 1024px a 7-column grid of two
* 355px rows — dark (2 columns), video (2), light (3) / light (3), light (2), dark (2). In the order quote 1, video,
* quotes 2–5. A card: five stars and the quote at the top; at the bottom a 48px round photo beside the name and
* company, and the company's logo at the right on the wide cards when you give one.
* Motion kept: the video plays muted and loops; it stays on its poster with reduced motion. Nothing else moves.
* Changed: Kalme's black and white cards become the inverted (foreground) and muted surfaces. The video is decorative.
*/
export type MosaicTestimonial = {
quote: string;
name: string;
company: string;
/** 48px round photo; decorative, the name sits beside it. */
photo?: string;
/** Out of 5. */
rating?: number;
/** Company logo, shown on the wide cards. */
logo?: { src: string; alt: string };
};
export type TestimonialsMosaicProps = {
/** Small line above the heading. */
label?: string;
title?: string;
lead?: string;
/** Up to five; the mosaic has five card places. */
testimonials?: MosaicTestimonial[];
/** Muted loop in the second place; the poster shows until it plays and instead of it with reduced motion. */
video?: { src: string; poster?: string };
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: "Brands we've grown, in their own words — from first launch to the campaigns that doubled their reach.",
testimonials: [
{
quote: "They found the story in our numbers and told it better than we could. Bookings were up a third by spring.",
name: "Amelia Hart",
company: "Brightline Studio",
photo: face("photo-1494790108377-be9c29b29330"),
},
{
quote: "Clear plans, honest reports and a team that picks up the phone. Our paid social finally pays for itself.",
name: "Daniel Okafor",
company: "Northfield Coffee",
photo: face("photo-1500648767791-00dcc994a43e"),
},
{
quote: "The rebrand landed the week we opened our second shop. Six months on, people still mention the campaign.",
name: "Lena Fischer",
company: "Fern & Field",
photo: face("photo-1438761681033-6461ffad8d80"),
},
{
quote: "Every test had a reason and every result came with a next step. It felt like having a growth team in-house.",
name: "Marcus Bell",
company: "Tidewater Apps",
photo: face("photo-1506794778202-cad84cf45f1d"),
},
{
quote: "Calm, thorough and quick. They turned a messy brief into a launch plan in two weeks.",
name: "Priya Raman",
company: "Lumen Health",
photo: face("photo-1573496359142-b8d87734a5a2"),
},
] as MosaicTestimonial[],
video: {
/* Pexels (free to use): an artist painting in a white studio — replace with your own clip. */
src: "https://videos.pexels.com/video-files/32316895/13783389_1080_1920_24fps.mp4",
poster: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&w=900&q=80",
},
};
/* Card places in Framer's order: tone, wide (shows the logo), and the columns it takes from 1024px. */
const slots = [
{ dark: true, wide: false, span: "lg:col-span-2" },
{ dark: false, wide: true, span: "lg:col-span-3" },
{ dark: false, wide: true, span: "lg:col-span-3" },
{ dark: false, wide: false, span: "lg:col-span-2" },
{ dark: true, wide: false, span: "lg:col-span-2" },
];
/* Tiles keep Framer's heights as a minimum, so a long quote grows its row instead of overflowing. */
const tile = "min-h-80 md:min-h-75.75 lg:min-h-88.75";
export function TestimonialsMosaic({
label = defaults.label,
title = defaults.title,
lead = defaults.lead,
testimonials = defaults.testimonials,
video = defaults.video,
className,
}: TestimonialsMosaicProps) {
const cards = testimonials.slice(0, slots.length).map((testimonial, i) => (
<Card key={testimonial.name} testimonial={testimonial} {...slots[i]} />
));
return (
<section className={cn("py-(--section-y)", className)}>
<div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)">
<div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24">
<div className="flex flex-col gap-2.5 md:flex-1">
{label && <p className="text-sm font-medium text-muted-foreground">{label}</p>}
<h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2>
</div>
{lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>}
</div>
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-7">
{cards[0]}
{video && (
<div aria-hidden className={cn("h-80 overflow-hidden rounded-xl bg-muted md:h-auto lg:col-span-2", tile)}>
<LoopVideo src={video.src} poster={video.poster} />
</div>
)}
{cards.slice(1)}
</div>
</div>
</section>
);
}
function Card({ testimonial, dark, wide, span }: { testimonial: MosaicTestimonial; dark: boolean; wide: boolean; span: string }) {
const rating = Math.max(0, Math.min(5, Math.round(testimonial.rating ?? 5)));
return (
<figure
className={cn(
"flex flex-col justify-between gap-6 rounded-xl p-5 lg:p-7.5",
dark ? "bg-foreground text-background" : "bg-muted text-foreground",
tile,
span,
)}
>
<div className="flex flex-col items-start gap-5">
{rating > 0 && (
<p role="img" aria-label={`${rating} out of 5 stars`} className="flex gap-1.5">
{Array.from({ length: rating }, (_, i) => (
<svg key={i} aria-hidden viewBox="0 0 20 20" fill="currentColor" className="size-5">
<path d="M10 1.8l2.5 5.2 5.7.7-4.2 3.9 1.1 5.6L10 14.4l-5.1 2.8 1.1-5.6-4.2-3.9 5.7-.7z" />
</svg>
))}
</p>
)}
<blockquote className="text-lead text-pretty">
<p>{testimonial.quote}</p>
</blockquote>
</div>
<figcaption className="flex items-center justify-between gap-2.5">
<span className="flex min-w-0 items-center gap-2.5">
{testimonial.photo && (
<img src={testimonial.photo} alt="" className="size-12 shrink-0 rounded-full bg-background/10 object-cover" />
)}
<span className="flex min-w-0 flex-col">
<span className="truncate font-medium">{testimonial.name}</span>
<span className={cn("truncate text-sm", dark ? "text-background/65" : "text-foreground/65")}>{testimonial.company}</span>
</span>
</span>
{wide && testimonial.logo && (
<img src={testimonial.logo.src} alt={testimonial.logo.alt} className="h-8.5 w-33.5 shrink-0 object-contain max-sm:hidden" />
)}
</figcaption>
</figure>
);
}
/* A muted video that plays and loops on its own, covering its frame; it stays on the poster with reduced motion. */
function LoopVideo({ src, poster }: { src: string; poster?: string }) {
const ref = React.useRef<HTMLVideoElement>(null);
React.useEffect(() => {
const video = ref.current;
if (!video) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => {
if (reduce.matches) video.pause();
else void video.play().catch(() => {});
};
apply();
reduce.addEventListener("change", apply);
return () => reduce.removeEventListener("change", apply);
}, []);
return <video ref={ref} src={src} poster={poster} muted loop playsInline preload="metadata" className="size-full object-cover" />;
}Installation
$ npx shadcn@latest add https://framebrick.co/r/blocks/testimonials-mosaic.json1. Add the block tokens to
app/globals.cssonce (theming).npx shadcn@latest add https://framebrick.co/r/blocks/tokens.json2. Copy the block into
components/testimonials-mosaic.tsx."use client"; import * as React from "react"; import { cn } from "@/lib/utils"; /* * Testimonials Mosaic — a split title, then five client quotes on dark and light cards of two widths around a short * looping video. * Source: marcframe/kalme → components/sections/TestimonialsSection.tsx (+ design-system TestimonialCard, LoopVideo, * SectionTitle split). * * Layout kept from Framer: the lead sits beside the heading from 768px, aligned to its bottom (up to 400px). Tiles, * at least Framer's height (a longer quote grows its row), 20px apart: one column of 320px below 768px, two columns of * 303px from 768px, and from 1024px a 7-column grid of two * 355px rows — dark (2 columns), video (2), light (3) / light (3), light (2), dark (2). In the order quote 1, video, * quotes 2–5. A card: five stars and the quote at the top; at the bottom a 48px round photo beside the name and * company, and the company's logo at the right on the wide cards when you give one. * Motion kept: the video plays muted and loops; it stays on its poster with reduced motion. Nothing else moves. * Changed: Kalme's black and white cards become the inverted (foreground) and muted surfaces. The video is decorative. */ export type MosaicTestimonial = { quote: string; name: string; company: string; /** 48px round photo; decorative, the name sits beside it. */ photo?: string; /** Out of 5. */ rating?: number; /** Company logo, shown on the wide cards. */ logo?: { src: string; alt: string }; }; export type TestimonialsMosaicProps = { /** Small line above the heading. */ label?: string; title?: string; lead?: string; /** Up to five; the mosaic has five card places. */ testimonials?: MosaicTestimonial[]; /** Muted loop in the second place; the poster shows until it plays and instead of it with reduced motion. */ video?: { src: string; poster?: string }; 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: "Brands we've grown, in their own words — from first launch to the campaigns that doubled their reach.", testimonials: [ { quote: "They found the story in our numbers and told it better than we could. Bookings were up a third by spring.", name: "Amelia Hart", company: "Brightline Studio", photo: face("photo-1494790108377-be9c29b29330"), }, { quote: "Clear plans, honest reports and a team that picks up the phone. Our paid social finally pays for itself.", name: "Daniel Okafor", company: "Northfield Coffee", photo: face("photo-1500648767791-00dcc994a43e"), }, { quote: "The rebrand landed the week we opened our second shop. Six months on, people still mention the campaign.", name: "Lena Fischer", company: "Fern & Field", photo: face("photo-1438761681033-6461ffad8d80"), }, { quote: "Every test had a reason and every result came with a next step. It felt like having a growth team in-house.", name: "Marcus Bell", company: "Tidewater Apps", photo: face("photo-1506794778202-cad84cf45f1d"), }, { quote: "Calm, thorough and quick. They turned a messy brief into a launch plan in two weeks.", name: "Priya Raman", company: "Lumen Health", photo: face("photo-1573496359142-b8d87734a5a2"), }, ] as MosaicTestimonial[], video: { /* Pexels (free to use): an artist painting in a white studio — replace with your own clip. */ src: "https://videos.pexels.com/video-files/32316895/13783389_1080_1920_24fps.mp4", poster: "https://images.unsplash.com/photo-1460661419201-fd4cecdf8a8b?auto=format&fit=crop&w=900&q=80", }, }; /* Card places in Framer's order: tone, wide (shows the logo), and the columns it takes from 1024px. */ const slots = [ { dark: true, wide: false, span: "lg:col-span-2" }, { dark: false, wide: true, span: "lg:col-span-3" }, { dark: false, wide: true, span: "lg:col-span-3" }, { dark: false, wide: false, span: "lg:col-span-2" }, { dark: true, wide: false, span: "lg:col-span-2" }, ]; /* Tiles keep Framer's heights as a minimum, so a long quote grows its row instead of overflowing. */ const tile = "min-h-80 md:min-h-75.75 lg:min-h-88.75"; export function TestimonialsMosaic({ label = defaults.label, title = defaults.title, lead = defaults.lead, testimonials = defaults.testimonials, video = defaults.video, className, }: TestimonialsMosaicProps) { const cards = testimonials.slice(0, slots.length).map((testimonial, i) => ( <Card key={testimonial.name} testimonial={testimonial} {...slots[i]} /> )); return ( <section className={cn("py-(--section-y)", className)}> <div className="mx-auto flex max-w-(--container) flex-col gap-12 px-(--gutter)"> <div className="flex flex-col gap-5 md:flex-row md:items-end md:gap-12 lg:gap-24"> <div className="flex flex-col gap-2.5 md:flex-1"> {label && <p className="text-sm font-medium text-muted-foreground">{label}</p>} <h2 className="font-heading text-h2 font-semibold tracking-tight text-balance">{title}</h2> </div> {lead && <p className="max-w-100 text-pretty text-muted-foreground md:flex-1">{lead}</p>} </div> <div className="grid gap-5 md:grid-cols-2 lg:grid-cols-7"> {cards[0]} {video && ( <div aria-hidden className={cn("h-80 overflow-hidden rounded-xl bg-muted md:h-auto lg:col-span-2", tile)}> <LoopVideo src={video.src} poster={video.poster} /> </div> )} {cards.slice(1)} </div> </div> </section> ); } function Card({ testimonial, dark, wide, span }: { testimonial: MosaicTestimonial; dark: boolean; wide: boolean; span: string }) { const rating = Math.max(0, Math.min(5, Math.round(testimonial.rating ?? 5))); return ( <figure className={cn( "flex flex-col justify-between gap-6 rounded-xl p-5 lg:p-7.5", dark ? "bg-foreground text-background" : "bg-muted text-foreground", tile, span, )} > <div className="flex flex-col items-start gap-5"> {rating > 0 && ( <p role="img" aria-label={`${rating} out of 5 stars`} className="flex gap-1.5"> {Array.from({ length: rating }, (_, i) => ( <svg key={i} aria-hidden viewBox="0 0 20 20" fill="currentColor" className="size-5"> <path d="M10 1.8l2.5 5.2 5.7.7-4.2 3.9 1.1 5.6L10 14.4l-5.1 2.8 1.1-5.6-4.2-3.9 5.7-.7z" /> </svg> ))} </p> )} <blockquote className="text-lead text-pretty"> <p>{testimonial.quote}</p> </blockquote> </div> <figcaption className="flex items-center justify-between gap-2.5"> <span className="flex min-w-0 items-center gap-2.5"> {testimonial.photo && ( <img src={testimonial.photo} alt="" className="size-12 shrink-0 rounded-full bg-background/10 object-cover" /> )} <span className="flex min-w-0 flex-col"> <span className="truncate font-medium">{testimonial.name}</span> <span className={cn("truncate text-sm", dark ? "text-background/65" : "text-foreground/65")}>{testimonial.company}</span> </span> </span> {wide && testimonial.logo && ( <img src={testimonial.logo.src} alt={testimonial.logo.alt} className="h-8.5 w-33.5 shrink-0 object-contain max-sm:hidden" /> )} </figcaption> </figure> ); } /* A muted video that plays and loops on its own, covering its frame; it stays on the poster with reduced motion. */ function LoopVideo({ src, poster }: { src: string; poster?: string }) { const ref = React.useRef<HTMLVideoElement>(null); React.useEffect(() => { const video = ref.current; if (!video) return; const reduce = window.matchMedia("(prefers-reduced-motion: reduce)"); const apply = () => { if (reduce.matches) video.pause(); else void video.play().catch(() => {}); }; apply(); reduce.addEventListener("change", apply); return () => reduce.removeEventListener("change", apply); }, []); return <video ref={ref} src={src} poster={poster} muted loop playsInline preload="metadata" className="size-full object-cover" />; }
Usage
import { TestimonialsMosaic } from "@/components/testimonials-mosaic";
<TestimonialsMosaic
title="What our clients say"
video={{ src: "/video/studio.mp4", poster: "/video/studio.jpg" }}
testimonials={[
{ quote: "They found the story in our numbers…", name: "Amelia Hart", company: "Brightline Studio", photo: "/people/amelia.jpg" },
]}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | Testimonials | Small muted line above the heading. |
| title / lead | string | — | Heading (h2) and the lead beside it from 768px (up to 400px). |
| testimonials | { quote, name, company, photo?, rating?, logo? }[] | 5 quotes | Up to five, in the mosaic's card places; rating out of 5 (default 5); the logo shows on the two wide cards. |
| video | { src, poster? } | a Pexels clip | Muted loop in the second place, decorative; the poster shows instead of it with reduced motion. |
| className | string | — | Classes for the <section>. |
- From 1024px a 7-column grid of two rows: dark (2 columns), video (2), light (3) / light (3), light (2), dark (2); two columns from 768px; one below.
- Tiles keep Kalme's heights (320 / 303 / 355px) as a minimum, so a longer quote grows its row instead of overflowing.
- Dark cards use the inverted surface (foreground), light cards the muted one. Each card is a <figure> with the quote in a <blockquote>.
AI prompt
Paste into v0, Lovable, Bolt or Claude to rebuild this block in your own project.
Create a React + Tailwind CSS block called TestimonialsMosaic (Next.js, client component). - <section className="py-(--section-y)">, container mx-auto max-w-(--container) px-(--gutter), column gap-12. - Split title: a small muted label and an h2 (font-heading text-h2 semibold); beside it from 768px (items-end) a muted lead up to 400px. - Grid gap-5: 1 / 2 (768px) / 7 (1024px) columns; tiles at least 320 / 303 / 355px tall, rounded-xl. Order: quote 1 (dark, 2 columns), a muted looping video tile (2 columns, aria-hidden, paused on its poster with reduced motion), quotes 2 and 3 (light, 3 columns, with the company logo at the right when given), quote 4 (light, 2), quote 5 (dark, 2). - Card (<figure>, p-5 / lg:p-7.5, justify-between): five 20px stars (role="img", "5 out of 5 stars") and the quote (text-lead) on top; at the bottom a 48px round photo beside the name (font-medium) and company (text-sm, 65%). Dark: bg-foreground text-background; light: bg-muted. - Only shadcn variables and block tokens; plain <img>.











