← alle entries

testimonial-wall

FC-0035testimonial-marqueeblockv1.0.0

Vier 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.

zelfde component — andere tokens
Binnen drie weken stond de site live, en de eerste aanvraag kwam dezelfde dag nog binnen.
Femke de Groot · UtrechtEigenaar, Praktijk Zon
Eindelijk een site die er op mijn telefoon net zo goed uitziet als op de laptop.
Youssef El Amrani · AmersfoortRijschool Oost
Ze luisterden écht. Het resultaat voelt als ons bedrijf, niet als een sjabloon.
Anouk Bakker · HaarlemStudio Vier
De boekingen lopen nu vanzelf. Ik hoef niets meer over te typen.
Daan Vos · ZeistHotel Linde
Korte lijnen, snelle antwoorden, geen gedoe.
Priya Nair · RotterdamDe Werf
Onze oude site was traag; deze laadt voordat je met je ogen knippert.
Tom Jansen · GroningenBakkerij Noord
Het onderhoud is geregeld, ik hoef er niet meer aan te denken.
Lotte Smit · LeidenKapsalon Bloem
Twee maanden na de lancering: dubbel zoveel offerteaanvragen.
Bram Visser · EdeAannemer Visser
Duidelijk, eerlijk en op tijd. Meer vraag ik niet.
Sara Mulder · BredaFysio Centrum
Ze luisterden écht. Het resultaat voelt als ons bedrijf, niet als een sjabloon.
Anouk Bakker · HaarlemStudio Vier
De boekingen lopen nu vanzelf. Ik hoef niets meer over te typen.
Daan Vos · ZeistHotel Linde
Korte lijnen, snelle antwoorden, geen gedoe.
Priya Nair · RotterdamDe Werf
Onze oude site was traag; deze laadt voordat je met je ogen knippert.
Tom Jansen · GroningenBakkerij Noord
Het onderhoud is geregeld, ik hoef er niet meer aan te denken.
Lotte Smit · LeidenKapsalon Bloem
Twee maanden na de lancering: dubbel zoveel offerteaanvragen.
Bram Visser · EdeAannemer Visser
Duidelijk, eerlijk en op tijd. Meer vraag ik niet.
Sara Mulder · BredaFysio Centrum
Binnen drie weken stond de site live, en de eerste aanvraag kwam dezelfde dag nog binnen.
Femke de Groot · UtrechtEigenaar, Praktijk Zon
Eindelijk een site die er op mijn telefoon net zo goed uitziet als op de laptop.
Youssef El Amrani · AmersfoortRijschool Oost
Korte lijnen, snelle antwoorden, geen gedoe.
Priya Nair · RotterdamDe Werf
Onze oude site was traag; deze laadt voordat je met je ogen knippert.
Tom Jansen · GroningenBakkerij Noord
Het onderhoud is geregeld, ik hoef er niet meer aan te denken.
Lotte Smit · LeidenKapsalon Bloem
Twee maanden na de lancering: dubbel zoveel offerteaanvragen.
Bram Visser · EdeAannemer Visser
Duidelijk, eerlijk en op tijd. Meer vraag ik niet.
Sara Mulder · BredaFysio Centrum
Binnen drie weken stond de site live, en de eerste aanvraag kwam dezelfde dag nog binnen.
Femke de Groot · UtrechtEigenaar, Praktijk Zon
Eindelijk een site die er op mijn telefoon net zo goed uitziet als op de laptop.
Youssef El Amrani · AmersfoortRijschool Oost
Ze luisterden écht. Het resultaat voelt als ons bedrijf, niet als een sjabloon.
Anouk Bakker · HaarlemStudio Vier
De boekingen lopen nu vanzelf. Ik hoef niets meer over te typen.
Daan Vos · ZeistHotel Linde
Het onderhoud is geregeld, ik hoef er niet meer aan te denken.
Lotte Smit · LeidenKapsalon Bloem
Twee maanden na de lancering: dubbel zoveel offerteaanvragen.
Bram Visser · EdeAannemer Visser
Duidelijk, eerlijk en op tijd. Meer vraag ik niet.
Sara Mulder · BredaFysio Centrum
Binnen drie weken stond de site live, en de eerste aanvraag kwam dezelfde dag nog binnen.
Femke de Groot · UtrechtEigenaar, Praktijk Zon
Eindelijk een site die er op mijn telefoon net zo goed uitziet als op de laptop.
Youssef El Amrani · AmersfoortRijschool Oost
Ze luisterden écht. Het resultaat voelt als ons bedrijf, niet als een sjabloon.
Anouk Bakker · HaarlemStudio Vier
De boekingen lopen nu vanzelf. Ik hoef niets meer over te typen.
Daan Vos · ZeistHotel Linde
Korte lijnen, snelle antwoorden, geen gedoe.
Priya Nair · RotterdamDe Werf
Onze oude site was traag; deze laadt voordat je met je ogen knippert.
Tom Jansen · GroningenBakkerij Noord
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); }