testimonial-grid
FC-0033testimonialblockv1.0.0Bento van citaten in drie kolommen: per kaart een toon (neutraal met raster, accent, contrast) en een hoogte, zodat het een compositie is. Kaarten komen scherp in beeld bij het scrollen (blur + zakken).
Wat klanten zeggen
Zes reacties uit de afgelopen maanden.
Binnen drie weken stond de site live, en de eerste aanvraag kwam dezelfde dag nog binnen.
Eindelijk een site die er op mijn telefoon net zo goed uitziet als op de laptop.
Ze luisterden écht. Het resultaat voelt als ons bedrijf, niet als een sjabloon.
De boekingen lopen nu vanzelf. Ik hoef niets meer over te typen.
Korte lijnen, snelle antwoorden, geen gedoe.
Onze oude site was traag; deze laadt voordat je met je ogen knippert.
blocks/testimonials/testimonial-grid.tsx
/**
* TestimonialGrid — een bento van citaten: drie kolommen, per kaart een toon
* (neutraal, accent, contrast) en een hoogte (normaal of hoog), zodat het
* geheel een compositie is en geen rooster. Kaarten komen scherp in beeld
* terwijl je scrolt (blur + zakken, per kaart iets later).
*
* De bron had zeven vaste kaarten met klantnamen, een grid-patroon in
* #4f4f4f2e en een eigen TimelineContent-wrapper. Hier: items van het
* project, toon per item, patroon op --border met color-mix, reveal via
* whileInView. De blur-reveal is verf (filter) en mag onder de motion-regels.
*/
"use client";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import type { Testimonial } from "./testimonial-card";
export type TestimonialTone = "neutral" | "accent" | "contrast";
export interface TestimonialGridItem extends Testimonial {
tone?: TestimonialTone;
/** Hoge kaart (neemt twee rijen). */
tall?: boolean;
}
export interface TestimonialGridProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
items: readonly TestimonialGridItem[];
title?: React.ReactNode;
subtitle?: React.ReactNode;
/** Kolommen op brede schermen (default 3); smaller valt hij terug naar 1. */
columns?: 2 | 3;
}
export function TestimonialGrid({ items, title, subtitle, columns = 3, className, style, ...rest }: TestimonialGridProps) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
return (
<section {...rest} className={["fp-tgrid", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-tgrid-kolommen" as string]: columns } as React.CSSProperties}>
{(title || subtitle) && (
<motion.header
className="fp-tgrid__kop"
initial={reduced ? false : { opacity: 0, y: -12, filter: "blur(8px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: true, amount: 0.4 }}
transition={veer}
>
{title && <h2 className="fp-tgrid__titel">{title}</h2>}
{subtitle && <p className="fp-tgrid__sub">{subtitle}</p>}
</motion.header>
)}
<div className="fp-tgrid__rooster">
{items.map((item, i) => (
<motion.figure
key={item.id}
className={["fp-tgrid__kaart", `fp-tgrid__kaart--${item.tone ?? "neutral"}`, item.tall ? "fp-tgrid__kaart--hoog" : ""].filter(Boolean).join(" ")}
initial={reduced ? false : { opacity: 0, y: -16, filter: "blur(10px)" }}
whileInView={{ opacity: 1, y: 0, filter: "blur(0px)" }}
viewport={{ once: true, amount: 0.3 }}
transition={{ ...veer, delay: reduced ? 0 : i * 0.08 }}
>
<blockquote className="fp-tgrid__quote">{item.quote}</blockquote>
<figcaption className="fp-tgrid__wie">
<span className="fp-tgrid__naam-vak">
<span className="fp-tgrid__naam">{item.name}</span>
{item.role && <span className="fp-tgrid__rol">{item.role}</span>}
</span>
{item.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className="fp-tgrid__beeld" src={item.image} alt="" width={64} height={64} loading="lazy" />
)}
</figcaption>
</motion.figure>
))}
</div>
</section>
);
}
blocks/testimonials/testimonial-card.tsx
/**
* Gedeeld datamodel + kaart van de vier testimonial-blocks. Geen eigen entry:
* de kaart is te klein om los te bestaan, elk block importeert hem.
*/
import type * as React from "react";
export interface Testimonial {
id: string;
quote: string;
name: string;
/** Functie/bedrijf onder de naam. */
role?: string;
/** Portret of logo; het project levert de URL. */
image?: string;
/** Vrije regel achter de naam (land, handle, datum). */
meta?: string;
}
interface KaartProps {
item: Testimonial;
className?: string;
/** Beeldvorm — het portret rond, een logo hoekig. */
imageShape?: "circle" | "rounded";
children?: React.ReactNode;
}
/* Eén stille kaart: citaat, dan wie het zei. */
export function TestimonialCard({ item, className, imageShape = "circle", children }: KaartProps) {
return (
<figure className={["fp-tcard", className].filter(Boolean).join(" ")}>
<blockquote className="fp-tcard__quote">{item.quote}</blockquote>
<figcaption className="fp-tcard__wie">
{item.image && (
// eslint-disable-next-line @next/next/no-img-element
<img className={`fp-tcard__beeld fp-tcard__beeld--${imageShape}`} src={item.image} alt="" width={40} height={40} loading="lazy" />
)}
<span className="fp-tcard__naam-vak">
<span className="fp-tcard__naam">
{item.name}
{item.meta && <span className="fp-tcard__meta"> {item.meta}</span>}
</span>
{item.role && <span className="fp-tcard__rol">{item.role}</span>}
</span>
</figcaption>
{children}
</figure>
);
}
blocks/testimonials/testimonials.css
/**
* Testimonial-blocks: gedeelde kaart + grid, stagger, wall, columns.
* Importeren in de app-root (de registry-site doet dat in app/globals.css).
*/
/* ── Gedeelde kaart ─────────────────────────────────────────────────────── */
.fp-tcard {
margin: 0; padding: 1.25rem;
background: var(--card); color: var(--card-foreground, var(--foreground));
border: 1px solid var(--border); border-radius: var(--radius);
box-shadow: 0 8px 24px color-mix(in srgb, var(--primary) 8%, transparent);
}
.fp-tcard__quote { margin: 0; font-size: 0.9375rem; line-height: 1.55; }
.fp-tcard__wie { display: flex; align-items: center; gap: 0.625rem; margin-top: 1rem; }
.fp-tcard__beeld { width: 2.5rem; height: 2.5rem; flex-shrink: 0; object-fit: cover; background: var(--muted); }
.fp-tcard__beeld--circle { border-radius: 50%; }
.fp-tcard__beeld--rounded { border-radius: calc(var(--radius) / 2); }
.fp-tcard__naam-vak { display: flex; flex-direction: column; line-height: 1.3; min-width: 0; }
.fp-tcard__naam { font-weight: 500; font-size: 0.875rem; }
.fp-tcard__meta { font-size: 0.75rem; color: var(--muted-foreground); font-weight: 400; }
.fp-tcard__rol { font-size: 0.8125rem; color: var(--muted-foreground); }
/* ── TestimonialGrid ────────────────────────────────────────────────────── */
.fp-tgrid { width: 100%; color: var(--foreground); }
.fp-tgrid__kop { max-width: 40rem; margin: 0 auto 2.5rem; text-align: center; }
.fp-tgrid__titel { margin: 0; font: inherit; font-weight: 500; font-size: clamp(1.5rem, 3vw, 2.25rem); line-height: 1.15; letter-spacing: -0.01em; }
.fp-tgrid__sub { margin: 0.5rem 0 0; color: var(--muted-foreground); }
.fp-tgrid__rooster {
display: grid; gap: 0.5rem;
grid-template-columns: 1fr;
grid-auto-rows: minmax(9rem, auto);
grid-auto-flow: dense;
}
@media (min-width: 900px) {
.fp-tgrid__rooster { grid-template-columns: repeat(var(--fp-tgrid-kolommen, 3), 1fr); }
}
.fp-tgrid__kaart {
position: relative; overflow: hidden; margin: 0;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 1.25rem;
border: 1px solid var(--border); border-radius: var(--radius);
}
.fp-tgrid__kaart--hoog { grid-row: span 2; }
.fp-tgrid__kaart--neutral { background: var(--card); color: var(--card-foreground, var(--foreground)); }
/* Het raster op de neutrale kaart: 1px-lijnen op --border, vervagend naar onderen. */
.fp-tgrid__kaart--neutral::before {
content: ""; position: absolute; inset: 0; pointer-events: none;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--border) 60%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--border) 60%, transparent) 1px, transparent 1px);
background-size: 50px 56px;
-webkit-mask-image: radial-gradient(ellipse 80% 50% at 50% 0%, currentColor 70%, transparent 110%);
mask-image: radial-gradient(ellipse 80% 50% at 50% 0%, currentColor 70%, transparent 110%);
}
.fp-tgrid__kaart--accent { background: var(--primary); color: var(--primary-foreground); border-color: transparent; }
.fp-tgrid__kaart--contrast { background: var(--foreground); color: var(--background); border-color: transparent; }
.fp-tgrid__quote { position: relative; margin: 0; font-size: 0.9375rem; line-height: 1.55; }
.fp-tgrid__wie { position: relative; display: flex; justify-content: space-between; align-items: flex-end; gap: 1rem; margin-top: 1.25rem; }
.fp-tgrid__naam-vak { display: flex; flex-direction: column; min-width: 0; }
.fp-tgrid__naam { font-weight: 600; font-size: 1.125rem; line-height: 1.2; }
.fp-tgrid__rol { font-size: 0.875rem; opacity: 0.8; }
.fp-tgrid__beeld { width: 4rem; height: 4rem; flex-shrink: 0; object-fit: cover; border-radius: var(--radius); background: var(--muted); }
/* ── StaggerTestimonials ────────────────────────────────────────────────── */
.fp-stagger {
position: relative; width: 100%; overflow: hidden;
background: color-mix(in srgb, var(--muted) 30%, transparent);
color: var(--foreground);
}
.fp-stagger__kaart {
position: absolute; left: 50%; top: 50%; margin: 0;
width: var(--fp-stagger-maat); height: var(--fp-stagger-maat);
padding: 2rem;
background: var(--card); color: var(--card-foreground, var(--foreground));
border: 2px solid var(--border);
cursor: pointer;
/* De afgeschuinde hoek rechtsboven (50px) — vorm, geen identiteit. */
clip-path: polygon(50px 0%, calc(100% - 50px) 0%, 100% 50px, 100% 100%, calc(100% - 50px) 100%, 50px 100%, 0 100%, 0 0);
transition:
transform var(--motion-smooth-duration) var(--motion-smooth),
background-color var(--motion-smooth-duration) var(--motion-smooth),
color var(--motion-smooth-duration) var(--motion-smooth),
border-color var(--motion-smooth-duration) var(--motion-smooth),
box-shadow var(--motion-smooth-duration) var(--motion-smooth);
}
.fp-stagger__kaart:hover { border-color: color-mix(in srgb, var(--primary) 50%, transparent); }
.fp-stagger__kaart:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-stagger__kaart[data-midden] {
background: var(--primary); color: var(--primary-foreground); border-color: var(--primary);
box-shadow: 0 8px 0 4px var(--border);
cursor: default;
}
/* De diagonale lijn langs de afgeschuinde hoek: sqrt(50²+50²) = 70.7px. */
.fp-stagger__schuine {
position: absolute; right: -2px; top: 48px;
width: 70.7px; height: 2px;
transform-origin: top right; transform: rotate(45deg);
background: var(--border);
}
.fp-stagger__beeld {
display: block; width: 3rem; height: 3.5rem; margin-bottom: 1rem;
object-fit: cover; object-position: top; background: var(--muted);
box-shadow: 3px 3px 0 var(--background);
}
.fp-stagger__quote { margin: 0; font-size: 1rem; font-weight: 500; line-height: 1.4; }
@media (min-width: 640px) { .fp-stagger__quote { font-size: 1.25rem; } }
.fp-stagger__wie {
position: absolute; left: 2rem; right: 2rem; bottom: 2rem;
font-size: 0.875rem; font-style: italic; color: var(--muted-foreground);
}
.fp-stagger__kaart[data-midden] .fp-stagger__wie { color: color-mix(in srgb, var(--primary-foreground) 80%, transparent); }
.fp-stagger__knoppen { position: absolute; left: 50%; bottom: 1rem; display: flex; gap: 0.5rem; transform: translateX(-50%); z-index: 20; }
.fp-stagger__knop {
display: flex; align-items: center; justify-content: center;
width: 3.5rem; height: 3.5rem;
appearance: none; cursor: pointer;
background: var(--background); color: var(--foreground);
border: 2px solid var(--border);
transition:
background-color var(--motion-instant-duration) var(--motion-instant),
color var(--motion-instant-duration) var(--motion-instant),
transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-stagger__knop:hover, .fp-stagger__knop[data-pressed] { background: var(--primary); color: var(--primary-foreground); }
.fp-stagger__knop[data-pressed] { transform: scale(0.96); }
.fp-stagger__knop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
/* ── TestimonialWall ────────────────────────────────────────────────────── */
.fp-wall {
position: relative; width: 100%; overflow: hidden;
display: flex; align-items: center; justify-content: center;
perspective: 300px;
background: var(--background); color: var(--foreground);
}
.fp-wall__vlak {
display: flex; align-items: center; gap: 1rem;
transform: translateX(-100px) translateZ(-100px) rotateX(20deg) rotateY(-10deg) rotateZ(20deg);
}
.fp-wall--plat .fp-wall__vlak { transform: none; }
.fp-wall__band { width: 13rem; height: 100%; }
.fp-wall__kaart { width: 100%; }
.fp-wall__rand { position: absolute; pointer-events: none; }
.fp-wall__rand--boven { inset: 0 0 auto 0; height: 25%; background: linear-gradient(to bottom, var(--background), transparent); }
.fp-wall__rand--onder { inset: auto 0 0 0; height: 25%; background: linear-gradient(to top, var(--background), transparent); }
.fp-wall__rand--links { inset: 0 auto 0 0; width: 25%; background: linear-gradient(to right, var(--background), transparent); }
.fp-wall__rand--rechts { inset: 0 0 0 auto; width: 25%; background: linear-gradient(to left, var(--background), transparent); }
/* ── TestimonialColumns ─────────────────────────────────────────────────── */
.fp-tcols { width: 100%; color: var(--foreground); }
.fp-tcols__kop { display: flex; flex-direction: column; align-items: center; max-width: 34rem; margin: 0 auto; text-align: center; }
.fp-tcols__eyebrow { padding: 0.25rem 1rem; border: 1px solid var(--border); border-radius: var(--radius); font-size: 0.875rem; }
.fp-tcols__titel { margin: 1.25rem 0 0; font: inherit; font-weight: 700; font-size: clamp(1.5rem, 4vw, 3rem); line-height: 1.1; letter-spacing: -0.02em; }
.fp-tcols__sub { margin: 1.25rem 0 0; color: var(--muted-foreground); }
.fp-tcols__vak {
display: flex; justify-content: center; gap: 1.5rem;
margin-top: 2.5rem; overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, transparent, currentColor 25%, currentColor 75%, transparent);
mask-image: linear-gradient(to bottom, transparent, currentColor 25%, currentColor 75%, transparent);
}
.fp-tcols__kolom { width: 20rem; max-width: 100%; height: 100%; }
.fp-tcols__kolom--2, .fp-tcols__kolom--3 { display: none; }
@media (min-width: 768px) { .fp-tcols__kolom--2 { display: flex; } }
@media (min-width: 1024px) { .fp-tcols__kolom--3 { display: flex; } }
.fp-tcols__kaart { padding: 2.5rem; border-radius: calc(var(--radius) * 2); }