testimonial-wall
FC-0035testimonial-marqueeblockv1.0.0Vier verticale banden korte reacties, gekanteld in een 3D-vlak, om en om omhoog en omlaag; randen vervagen naar de achtergrond. Componeert marquee voor het schuiven.
blocks/testimonials/testimonial-wall.tsx
/**
* TestimonialWall — vier verticale banden met korte reacties, gekanteld in
* een 3D-vlak (perspective + rotateX/Y/Z), om en om omhoog en omlaag; de
* randen vervagen naar de achtergrond. Componeert `marquee` (FC-0029) — de
* bron droeg zijn eigen marquee met Tailwind-keyframes mee, plus radix-avatar
* en shadcn-card; die drie zijn vervallen.
*
* Het kantelen is een statische transform op het vlak; alleen de banden
* bewegen (Framer, ambient, stil bij reduced motion — dat regelt marquee).
*/
"use client";
import type * as React from "react";
import { Marquee } from "../../primitives/ui/marquee";
import { TestimonialCard, type Testimonial } from "./testimonial-card";
export interface TestimonialWallProps extends React.HTMLAttributes<HTMLDivElement> {
items: readonly Testimonial[];
/** Aantal banden (default 4). */
columns?: 2 | 3 | 4;
/** Seconden per doorloop van een band. */
duration?: number;
/** Hoogte van de muur, structureel (default 24rem). */
height?: string;
/** Kanteling uit (plat vlak). */
flat?: boolean;
}
export function TestimonialWall({ items, columns = 4, duration = 40, height = "24rem", flat = false, className, style, ...rest }: TestimonialWallProps) {
/* Elke band begint op een andere plek in de lijst, zodat ze niet gelijk lopen. */
const banden = Array.from({ length: columns }, (_, k) => {
const start = Math.floor((items.length / columns) * k);
return [...items.slice(start), ...items.slice(0, start)];
});
return (
<div {...rest} className={["fp-wall", flat ? "fp-wall--plat" : "", className].filter(Boolean).join(" ")} style={{ ...style, height }}>
<div className="fp-wall__vlak">
{banden.map((band, k) => (
<Marquee key={k} direction={k % 2 === 0 ? "down" : "up"} duration={duration + k * 3} pauseOnHover fade={false} gap="1rem" className="fp-wall__band">
{band.map((item) => (
<TestimonialCard key={item.id} item={item} className="fp-wall__kaart" />
))}
</Marquee>
))}
</div>
<span aria-hidden="true" className="fp-wall__rand fp-wall__rand--boven" />
<span aria-hidden="true" className="fp-wall__rand fp-wall__rand--onder" />
<span aria-hidden="true" className="fp-wall__rand fp-wall__rand--links" />
<span aria-hidden="true" className="fp-wall__rand fp-wall__rand--rechts" />
</div>
);
}
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); }