← alle entries

stagger-testimonials

FC-0034testimonial-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); }