← alle entries

testimonial-columns

FC-0036testimonial-columnsblockv1.0.0

Kop met daaronder drie kolommen citaten die elk in hun eigen tempo omhoog schuiven en boven en onder vervagen; smal één kolom, middelbreed twee. Componeert marquee.

zelfde component — andere tokens
Reviews

Wat onze klanten zeggen

Negen reacties, drie kolommen, elk in eigen tempo.

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
blocks/testimonials/testimonial-columns.tsx
/**
 * TestimonialColumns — een kop met daaronder drie kolommen citaten die elk in
 * hun eigen tempo omhoog schuiven; boven en onder vervagen ze weg. Op smalle
 * schermen blijft één kolom over, op middelbreed twee. Componeert `marquee`
 * (FC-0029) voor het schuiven — de bron animeerde translateY zelf met een
 * losse duur per kolom en dubbelde de items met een Fragment.
 *
 * De kop komt in beeld met het smooth-token (de bron: 0.8s cubic-bezier).
 */

"use client";

import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { Marquee } from "../../primitives/ui/marquee";
import { TestimonialCard, type Testimonial } from "./testimonial-card";

export interface TestimonialColumnsProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  items: readonly Testimonial[];
  /** Het label boven de titel ("Reviews"). */
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  subtitle?: React.ReactNode;
  /** Seconden per doorloop per kolom (default 15, 19, 17 — bewust ongelijk). */
  durations?: readonly [number, number, number];
  /** Hoogte van het kolommenvak, structureel (default 46rem). */
  height?: string;
}

export function TestimonialColumns({ items, eyebrow, title, subtitle, durations = [15, 19, 17], height = "46rem", className, style, ...rest }: TestimonialColumnsProps) {
  const reduced = useReducedMotion() ?? false;
  const veer = useSpringTransition("smooth", { mass: "content" });
  const per = Math.ceil(items.length / 3);
  const kolommen = [items.slice(0, per), items.slice(per, per * 2), items.slice(per * 2)].filter((k) => k.length > 0);

  return (
    <section {...rest} className={["fp-tcols", className].filter(Boolean).join(" ")} style={style}>
      {(eyebrow || title || subtitle) && (
        <motion.header
          className="fp-tcols__kop"
          initial={reduced ? false : { opacity: 0, y: 20 }}
          whileInView={{ opacity: 1, y: 0 }}
          viewport={{ once: true, amount: 0.5 }}
          transition={{ ...veer, delay: 0.1 }}
        >
          {eyebrow && <span className="fp-tcols__eyebrow">{eyebrow}</span>}
          {title && <h2 className="fp-tcols__titel">{title}</h2>}
          {subtitle && <p className="fp-tcols__sub">{subtitle}</p>}
        </motion.header>
      )}
      <div className="fp-tcols__vak" style={{ maxHeight: height }}>
        {kolommen.map((kolom, k) => (
          <Marquee key={k} direction="up" duration={durations[k as 0 | 1 | 2]} fade={false} gap="1.5rem" className={`fp-tcols__kolom fp-tcols__kolom--${k + 1}`}>
            {kolom.map((item) => (
              <TestimonialCard key={item.id} item={item} className="fp-tcols__kaart" />
            ))}
          </Marquee>
        ))}
      </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); }