← alle entries

stagger-testimonials

FC-0034Testimonials & logo'stestimonial-carouselblockv1.0.0

Citaten als een rij scheve kaarten met een afgeschuinde hoek; de middelste staat hoog in de merkkleur. Klik op een kaart en hij schuift naar het midden; pijlen doen één stap.

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

Klik op een zijkaart of gebruik de pijlen; alleen transform beweegt.

blocks/testimonials/stagger-testimonials.tsx
/**
 * StaggerTestimonials — citaten als een rij scheve kaarten met een
 * afgeschuinde hoek; de middelste staat hoog en in de merkkleur, de rest
 * hangt links en rechts om en om iets omhoog en omlaag. Klik op een kaart en
 * hij schuift naar het midden; pijlen doen één stap.
 *
 * De bron roteerde de array en gaf elke kaart een Math.random-key (alles
 * hermount per stap). Hier blijft de lijst staan en verschuift alleen een
 * index: elke kaart houdt zijn element, dus de CSS-transitie op transform
 * doet het werk — één motor, motion-token smooth. Kaartmaat 365/290px uit de
 * bron, via matchMedia.
 */

"use client";

import { useEffect, useState } from "react";
import type * as React from "react";
import { usePress } from "../../primitives/ui/press";
import type { Testimonial } from "./testimonial-card";

export interface StaggerTestimonialsProps extends React.HTMLAttributes<HTMLDivElement> {
  items: readonly Testimonial[];
  /** Index van de kaart die in het midden begint. */
  initialIndex?: number;
  /** Hoogte van het podium, structureel (default 37.5rem). */
  height?: string;
  labels?: { previous?: string; next?: string };
}

interface KaartProps {
  item: Testimonial;
  positie: number;
  maat: number;
  onKies: () => void;
}

function Kaart({ item, positie, maat, onKies }: KaartProps) {
  const midden = positie === 0;
  const { pressed, pressProps } = usePress({ onPress: onKies, disabled: midden });
  const oneven = Math.abs(positie) % 2 === 1;
  const ty = midden ? -65 : oneven ? 15 : -15;
  const hoek = midden ? 0 : oneven ? 2.5 : -2.5;
  return (
    <figure
      {...pressProps}
      role={midden ? undefined : "button"}
      tabIndex={midden ? -1 : 0}
      aria-label={midden ? undefined : `Toon citaat van ${item.name}`}
      className="fp-stagger__kaart"
      data-midden={midden ? "" : undefined}
      data-pressed={pressed ? "" : undefined}
      style={{
        ["--fp-stagger-maat" as string]: `${maat}px`,
        transform: `translate(-50%, -50%) translateX(${(maat / 1.5) * positie}px) translateY(${ty}px) rotate(${hoek}deg)`,
        zIndex: midden ? 10 : 5 - Math.min(4, Math.abs(positie)),
      } as React.CSSProperties}
    >
      <span aria-hidden="true" className="fp-stagger__schuine" />
      {item.image && (
        // eslint-disable-next-line @next/next/no-img-element
        <img className="fp-stagger__beeld" src={item.image} alt="" width={48} height={56} loading="lazy" />
      )}
      <blockquote className="fp-stagger__quote">{item.quote}</blockquote>
      <figcaption className="fp-stagger__wie">
        — {item.name}
        {item.role ? `, ${item.role}` : ""}
      </figcaption>
    </figure>
  );
}

function Pijl({ richting }: { richting: "links" | "rechts" }) {
  return (
    <svg aria-hidden="true" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
      {richting === "links" ? <path d="M15 6l-6 6 6 6" /> : <path d="M9 6l6 6-6 6" />}
    </svg>
  );
}

function Knop({ label, richting, onPress }: { label: string; richting: "links" | "rechts"; onPress: () => void }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className="fp-stagger__knop" aria-label={label} data-pressed={pressed ? "" : undefined}>
      <Pijl richting={richting} />
    </button>
  );
}

export function StaggerTestimonials({ items, initialIndex = 0, height = "37.5rem", labels, className, style, ...rest }: StaggerTestimonialsProps) {
  const [actief, setActief] = useState(initialIndex);
  const [maat, setMaat] = useState(365);
  const n = items.length;

  useEffect(() => {
    const mq = window.matchMedia("(min-width: 640px)");
    const zet = () => setMaat(mq.matches ? 365 : 290);
    zet();
    mq.addEventListener("change", zet);
    return () => mq.removeEventListener("change", zet);
  }, []);

  /* Positie t.o.v. het midden, kortste weg rond de cirkel (-n/2 … n/2). */
  const positie = (i: number) => {
    let p = (i - actief) % n;
    if (p > n / 2) p -= n;
    if (p < -n / 2) p += n;
    return p;
  };
  const stap = (d: number) => setActief((a) => (a + d + n) % n);

  return (
    <div {...rest} className={["fp-stagger", className].filter(Boolean).join(" ")} style={{ ...style, height }}>
      {items.map((item, i) => (
        <Kaart key={item.id} item={item} positie={positie(i)} maat={maat} onKies={() => setActief(i)} />
      ))}
      <div className="fp-stagger__knoppen">
        <Knop label={labels?.previous ?? "Vorige"} richting="links" onPress={() => stap(-1)} />
        <Knop label={labels?.next ?? "Volgende"} richting="rechts" onPress={() => stap(1)} />
      </div>
    </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); }

/* ── TestimonialSpotlight ───────────────────────────────────────────────── */

.fp-spotlight { display: flex; flex-direction: column; align-items: center; gap: 3rem; width: 100%; padding: 3rem 1rem; box-sizing: border-box; background: var(--background); color: var(--foreground); }
.fp-spotlight__kop { display: flex; flex-direction: column; align-items: center; gap: 1rem; max-width: 56rem; text-align: center; }
.fp-spotlight__titel { margin: 0; font: inherit; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; font-size: clamp(2rem, 6vw, 4.5rem); }
.fp-spotlight__woord { display: inline-block; margin-right: 0.3em; will-change: transform, filter; }
.fp-spotlight__sub { margin: 0; max-width: 40rem; font-size: 1rem; line-height: 1.6; color: var(--muted-foreground); }
@media (min-width: 48rem) { .fp-spotlight__sub { font-size: 1.125rem; } }
.fp-spotlight__podium { display: flex; flex-direction: column; align-items: center; gap: 2rem; width: 100%; max-width: 44rem; }
.fp-spotlight__citaat { margin: 0; text-align: center; }
.fp-spotlight__citaat blockquote { margin: 0 0 1.25rem; font-size: clamp(1.125rem, 2.4vw, 1.5rem); line-height: 1.5; text-wrap: balance; }
.fp-spotlight__citaat figcaption { display: flex; flex-direction: column; gap: 0.125rem; font-size: 0.875rem; }
.fp-spotlight__naam { font-weight: 600; }
.fp-spotlight__rol { color: var(--muted-foreground); }
.fp-spotlight__portretten { display: flex; align-items: center; justify-content: center; gap: 0.75rem; }
.fp-spotlight__portret {
  display: inline-flex; align-items: center; justify-content: center; overflow: hidden; width: 2.75rem; height: 2.75rem; padding: 0; border-radius: 50%;
  border: 2px solid transparent; background: var(--muted); color: var(--foreground); cursor: pointer; outline: none; opacity: 0.55;
  transition: transform var(--motion-pop-duration) var(--motion-pop), opacity var(--motion-default-duration) var(--motion-default), border-color var(--motion-default-duration) var(--motion-default);
}
.fp-spotlight__portret img { width: 100%; height: 100%; object-fit: cover; }
.fp-spotlight__portret[data-actief] { transform: scale(1.3); opacity: 1; border-color: var(--primary); }
.fp-spotlight__portret[data-pressed] { transform: scale(0.92); }
.fp-spotlight__portret:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-spotlight__initiaal { font-size: 0.875rem; font-weight: 600; }