← alle entries

testimonial-spotlight

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

Een kop die woord voor woord scherp komt zodra de sectie in beeld is, en daaronder één citaat in de schijnwerper met een rij portretten: het gekozen portret groter en omrand. Het citaat wisselt vanzelf, stopt onder de muis, bij focus en onder reduced motion; een portret aantikken kiest direct.

zelfde component — andere tokens

Gebouwd voor teams die opleveren

Sluit je aan bij honderden ondernemers die bouwen met onderdelen die gewoon werken.

“Elke interactie voelt afgewerkt en bedoeld. Onze klanten merken het verschil meteen.”
Sanne MulderProductontwerper
blocks/testimonials/testimonial-spotlight.tsx
/**
 * TestimonialSpotlight — een kop die woord voor woord scherp komt zodra de
 * sectie in beeld is, en daaronder één citaat in de schijnwerper met een rij
 * portretten: het gekozen portret staat groter en omrand, de rest kleiner.
 * Het citaat wisselt vanzelf (ambient), stopt onder de muis, bij focus en
 * onder reduced motion; klikken op een portret kiest direct.
 *
 * Variant naast stagger-testimonials (FC-0034): die is een rij scheve
 * kaarten, deze één citaat met portret-navigatie. De bron droeg een
 * TestimonialCarousel-util die niet mee kwam; de carrousel is hier zelf
 * gebouwd, zonder radix/avatar/card. Portretten via usePress.
 */

"use client";

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

export interface TestimonialSpotlightProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  items: readonly Testimonial[];
  title: string;
  subtitle?: React.ReactNode;
  /** Seconden per citaat; 0 = niet vanzelf wisselen. */
  interval?: number;
  index?: number;
  onIndexChange?: (index: number) => void;
}

function Portret({ item, actief, onKies, label }: { item: Testimonial; actief: boolean; onKies: () => void; label: string }) {
  const { pressed, pressProps } = usePress({ onPress: onKies });
  return (
    <button {...pressProps} type="button" role="tab" aria-selected={actief} aria-label={label} className="fp-spotlight__portret" data-actief={actief ? "" : undefined} data-pressed={pressed ? "" : undefined}>
      {item.image ? (
        /* eslint-disable-next-line @next/next/no-img-element -- copy-in entry, url van het project */
        <img src={item.image} alt="" draggable={false} />
      ) : (
        <span className="fp-spotlight__initiaal" aria-hidden="true">{item.name.trim().charAt(0)}</span>
      )}
    </button>
  );
}

export function TestimonialSpotlight({ items, title, subtitle, interval = 5, index: indexProp, onIndexChange, className, ...rest }: TestimonialSpotlightProps) {
  const reduced = useReducedMotion() ?? false;
  const [eigen, setEigen] = useState(0);
  const [stil, setStil] = useState(false);
  const index = Math.min(indexProp ?? eigen, Math.max(items.length - 1, 0));
  const woord = useSpringTransition("default");
  const wissel = useSpringTransition("smooth");
  const zet = (i: number) => {
    if (indexProp === undefined) setEigen(i);
    onIndexChange?.(i);
  };

  useEffect(() => {
    if (reduced || stil || interval <= 0 || items.length < 2) return;
    const t = window.setInterval(() => zet((index + 1) % items.length), interval * 1000);
    return () => window.clearInterval(t);
    // eslint-disable-next-line react-hooks/exhaustive-deps -- zet is stabiel genoeg; index is de klok
  }, [reduced, stil, interval, items.length, index]);

  const huidig = items[index];
  const woorden = title.split(" ");

  return (
    <section
      {...rest}
      className={["fp-spotlight", className].filter(Boolean).join(" ")}
      onPointerEnter={(e) => { setStil(true); rest.onPointerEnter?.(e); }}
      onPointerLeave={(e) => { setStil(false); rest.onPointerLeave?.(e); }}
      onFocus={(e) => { setStil(true); rest.onFocus?.(e); }}
      onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setStil(false); rest.onBlur?.(e); }}
    >
      <div className="fp-spotlight__kop">
        <h2 className="fp-spotlight__titel">
          <span className="fp-sr-only">{title}</span>
          <span aria-hidden="true">
            {woorden.map((w, i) => (
              <motion.span
                key={`${w}-${i}`}
                className="fp-spotlight__woord"
                initial={reduced ? false : { opacity: 0, filter: "blur(6px)", y: 12 }}
                whileInView={{ opacity: 1, filter: "blur(0px)", y: 0 }}
                viewport={{ once: true }}
                transition={{ ...woord, delay: reduced ? 0 : i * 0.08 }}
              >
                {w}
              </motion.span>
            ))}
          </span>
        </h2>
        {subtitle && (
          <motion.p className="fp-spotlight__sub" initial={reduced ? false : { opacity: 0 }} whileInView={{ opacity: 1 }} viewport={{ once: true }} transition={{ ...woord, delay: reduced ? 0 : 0.5 }}>
            {subtitle}
          </motion.p>
        )}
      </div>

      {huidig && (
        <div className="fp-spotlight__podium">
          <AnimatePresence mode="wait" initial={false}>
            <motion.figure key={huidig.id} className="fp-spotlight__citaat" initial={reduced ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: -10 }} transition={wissel}>
              <blockquote>“{huidig.quote}”</blockquote>
              <figcaption>
                <span className="fp-spotlight__naam">{huidig.name}</span>
                {huidig.role && <span className="fp-spotlight__rol">{huidig.role}</span>}
              </figcaption>
            </motion.figure>
          </AnimatePresence>
          <div className="fp-spotlight__portretten" role="tablist" aria-label={title}>
            {items.map((item, i) => (
              <Portret key={item.id} item={item} actief={i === index} onKies={() => zet(i)} label={item.name} />
            ))}
          </div>
        </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); }

/* ── 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; }