← alle entries

team-drawer

FC-0134Sectiesteamblockv1.0.0

Het team als raster van portretten; op hover kleurt het beeld grijs en schuift een balk met naam, functie en pijl omhoog. Een tik opent een schermhoog paneel van rechts (overlay + spring) met sluitknop, teller n/totaal, portret, naam, functie, biografie en links; de inhoud verschijnt zodra het paneel staat. Escape en de overlay sluiten; de pagina erachter scrolt niet mee. Componeert button.

zelfde component — andere tokens

De mensen erachter

Vijf mensen, één studio. Tik op een kaart om te lezen wie ze zijn en waar ze aan werken.

Kom over een kaart voor naam en functie; tik voor het paneel (Escape sluit).

blocks/team/team-drawer.tsx
/**
 * TeamDrawer — het team als raster van portretten; op hover kleurt het beeld
 * grijs en schuift een balk met naam, functie en pijl omhoog. Een tik opent
 * een schermhoog paneel van rechts (overlay + spring) met sluitknop, teller
 * "n/totaal", portret, naam, functie, de biografie en links; de inhoud
 * verschijnt zodra het paneel staat. Escape en de overlay sluiten; de
 * pagina achter het paneel scrolt niet mee.
 *
 * Geoogst uit 21st.dev Information Drawer (hyperiux): gsap.to/fromTo →
 * framer springs op --motion-smooth (overlay-opacity, paneel-translateX,
 * inhoud-opacity met vertraging); dangerouslySetInnerHTML-bio → bio als
 * ReactNode; #ffffff/#111111 backgroundColor/textColor-props → --card/
 * --foreground; vw-typografie → rem met clamp; de vaste WordPress-vorm
 * (featuredImage.node.sourceUrl, teams.designation) → members-prop;
 * LinkedIn-svg → links-slot.
 */

"use client";

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

export interface TeamMember {
  id: string;
  name: string;
  role: string;
  image: string;
  bio: React.ReactNode;
  /** Tweede beeld onderaan het paneel. */
  extraImage?: string;
  links?: readonly { label: string; href: string; icon?: React.ReactNode }[];
}
export interface TeamDrawerProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
  members: readonly TeamMember[];
  title?: React.ReactNode;
  description?: React.ReactNode;
  /** Breedte van het paneel op brede schermen (55%). */
  drawerWidth?: string;
  onOpen?: (member: TeamMember) => void;
  labels?: Partial<Record<"open" | "close" | "empty" | "drawer", string>>;
}

const PIJL = (
  <svg aria-hidden="true" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round"><path d="M7 17L17 7M8 7h9v9" /></svg>
);

function Kaart({ m, onPress, label }: { m: TeamMember; onPress: () => void; label: string }) {
  const { pressed, pressProps } = usePress({ onPress });
  return (
    <button {...pressProps} type="button" className="fp-team__kaart" data-pressed={pressed ? "" : undefined} aria-label={`${label}: ${m.name}`}>
      {/* eslint-disable-next-line @next/next/no-img-element -- portretten van het project */}
      <img className="fp-team__beeld" src={m.image} alt="" loading="lazy" />
      <span className="fp-team__plus" aria-hidden="true">+</span>
      <span className="fp-team__balk" aria-hidden="true">
        <span className="fp-team__balk-tekst">
          <span className="fp-team__naam">{m.name}</span>
          <span className="fp-team__functie">{m.role}</span>
        </span>
        <span className="fp-team__pijl">{PIJL}</span>
      </span>
    </button>
  );
}

export function TeamDrawer({ members, title, description, drawerWidth = "55%", onOpen, labels, className, style, ...rest }: TeamDrawerProps) {
  const t = { open: "Lees meer over", close: "Sluiten", empty: "Nog geen teamleden.", drawer: "Teamlid", ...labels };
  const reduced = useReducedMotion() ?? false;
  const glad = useSpringTransition("smooth", { mass: "system" });
  const zacht = useSpringTransition("default", { mass: "content" });
  const [gekozen, setGekozen] = useState<TeamMember | null>(null);
  const laatsteKnop = useRef<HTMLElement | null>(null);
  const index = gekozen ? members.findIndex((m) => m.id === gekozen.id) : -1;

  useEffect(() => {
    if (!gekozen) return;
    const vorige = document.body.style.overflow;
    document.body.style.overflow = "hidden";
    const toets = (e: KeyboardEvent) => { if (e.key === "Escape") { setGekozen(null); laatsteKnop.current?.focus?.(); } };
    window.addEventListener("keydown", toets);
    return () => { document.body.style.overflow = vorige; window.removeEventListener("keydown", toets); };
  }, [gekozen]);

  const open = (m: TeamMember) => { laatsteKnop.current = document.activeElement as HTMLElement | null; setGekozen(m); onOpen?.(m); };
  const sluit = () => { setGekozen(null); laatsteKnop.current?.focus?.(); };

  return (
    <section {...rest} className={["fp-team", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-team-paneel" as string]: drawerWidth } as React.CSSProperties}>
      {(title || description) && (
        <div className="fp-team__kop">
          {title && <h2 className="fp-team__titel">{title}</h2>}
          {description && <p className="fp-team__intro">{description}</p>}
        </div>
      )}
      {members.length > 0 ? (
        <div className="fp-team__raster" aria-hidden={gekozen ? true : undefined}>
          {members.map((m) => <Kaart key={m.id} m={m} label={t.open} onPress={() => open(m)} />)}
        </div>
      ) : (
        <p className="fp-team__leeg">{t.empty}</p>
      )}
      <AnimatePresence>
        {gekozen && (
          <div className="fp-team__laag" key="laag">
            <motion.div className="fp-team__overlay" onClick={sluit} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={reduced ? { duration: 0 } : glad} />
            <motion.aside
              className="fp-team__paneel"
              role="dialog"
              aria-modal="true"
              aria-label={`${t.drawer}: ${gekozen.name}`}
              initial={reduced ? { opacity: 0 } : { x: "100%" }}
              animate={reduced ? { opacity: 1 } : { x: 0 }}
              exit={reduced ? { opacity: 0 } : { x: "100%" }}
              transition={glad}
            >
              <motion.div className="fp-team__inhoud" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={reduced ? { duration: 0 } : { ...zacht, delay: 0.25 }}>
                <div className="fp-team__paneelkop">
                  <Button variant="ghost" className="fp-team__sluit" aria-label={t.close} onPress={sluit} autoFocus>
                    <svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round"><path d="M4 4l16 16M20 4L4 20" /></svg>
                  </Button>
                  <span className="fp-team__teller">{index + 1}/{members.length}</span>
                </div>
                <hr className="fp-team__lijn" />
                <div className="fp-team__profiel">
                  {/* eslint-disable-next-line @next/next/no-img-element -- portretten van het project */}
                  <img className="fp-team__portret" src={gekozen.image} alt={gekozen.name} />
                  <div>
                    <h3 className="fp-team__paneelnaam">{gekozen.name}</h3>
                    <p className="fp-team__paneelfunctie">{gekozen.role}</p>
                  </div>
                </div>
                <div className="fp-team__bio">{gekozen.bio}</div>
                {gekozen.links && gekozen.links.length > 0 && (
                  <div className="fp-team__links">
                    {gekozen.links.map((l) => (
                      <a key={l.href} className="fp-team__link" href={l.href} target="_blank" rel="noopener noreferrer" aria-label={`${gekozen.name}: ${l.label}`}>{l.icon ?? l.label}</a>
                    ))}
                  </div>
                )}
                {gekozen.extraImage && (
                  // eslint-disable-next-line @next/next/no-img-element -- beeld van het project
                  <img className="fp-team__extra" src={gekozen.extraImage} alt="" loading="lazy" />
                )}
              </motion.div>
            </motion.aside>
          </div>
        )}
      </AnimatePresence>
    </section>
  );
}
blocks/team/team.css
/** TeamDrawer — importeren in de app-root (registry-site: app/globals.css). */

.fp-team { position: relative; width: 100%; color: var(--foreground); }
.fp-team__kop { display: flex; flex-direction: column; gap: 1rem; margin-bottom: 2.5rem; }
.fp-team__titel { margin: 0; font: inherit; font-size: clamp(2rem, 5.7vw, 4.5rem); font-weight: 600; line-height: 1.1; text-transform: uppercase; }
.fp-team__intro { margin: 0; max-width: 70%; font: inherit; font-size: clamp(1rem, 1.9vw, 1.5rem); font-weight: 500; line-height: 1.3; }
.fp-team__raster { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: 1.5rem; }
.fp-team__kaart { position: relative; display: block; width: 100%; aspect-ratio: 5 / 6; padding: 0; appearance: none; border: 0; border-radius: var(--radius); overflow: hidden; background: var(--muted); color: var(--background); cursor: pointer; text-align: left; }
.fp-team__kaart:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-team__beeld { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: filter var(--motion-default-duration) var(--motion-default), transform var(--motion-default-duration) var(--motion-default); }
.fp-team__kaart:hover .fp-team__beeld, .fp-team__kaart:focus-visible .fp-team__beeld { filter: grayscale(1); }
.fp-team__kaart[data-pressed] .fp-team__beeld { transform: scale(0.98); }
.fp-team__plus { position: absolute; top: 0.75rem; right: 0.75rem; z-index: 1; display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; border-radius: 50%; background: color-mix(in srgb, var(--foreground) 40%, transparent); color: var(--background); backdrop-filter: blur(12px); font-size: 1.25rem; line-height: 1; opacity: 0; transition: opacity var(--motion-default-duration) var(--motion-default); }
.fp-team__balk { position: absolute; inset: auto 0 0 0; display: flex; align-items: flex-end; justify-content: space-between; gap: 0.75rem; padding: 0.875rem 1rem; background: color-mix(in srgb, var(--foreground) 40%, transparent); backdrop-filter: blur(12px); transform: translateY(100%); transition: transform var(--motion-default-duration) var(--motion-default); }
.fp-team__kaart:hover .fp-team__balk, .fp-team__kaart:focus-visible .fp-team__balk { transform: none; }
.fp-team__balk-tekst { display: flex; flex-direction: column; min-width: 0; }
.fp-team__naam { font: inherit; font-size: 1.125rem; font-weight: 600; line-height: 1.3; text-transform: uppercase; }
.fp-team__functie { font: inherit; font-size: 0.8125rem; line-height: 1.3; }
.fp-team__pijl { display: inline-flex; font-size: 1.5rem; }
.fp-team__leeg { margin: 0; padding: 2.5rem 0; font: inherit; color: var(--muted-foreground); }
@media (hover: none) {
  .fp-team__plus { opacity: 1; }
  .fp-team__balk { transform: none; }
}
@media (max-width: 47.99rem) {
  .fp-team__intro { max-width: 100%; }
}

.fp-team__laag { position: fixed; inset: 0; z-index: 60; }
.fp-team__overlay { position: absolute; inset: 0; background: var(--overlay, color-mix(in srgb, var(--foreground) 35%, transparent)); }
.fp-team__paneel { position: absolute; inset: 0 0 0 auto; width: var(--fp-team-paneel, 55%); max-width: 100%; overflow-y: auto; overflow-x: hidden; background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: var(--shadow-overlay); }
.fp-team__inhoud { display: flex; flex-direction: column; gap: 1.5rem; padding: 2.5rem clamp(1.25rem, 5vw, 4rem) 3rem; }
.fp-team__paneelkop { display: flex; align-items: center; justify-content: space-between; }
.fp-team__sluit { width: 2.75rem; height: 2.75rem; padding: 0; justify-content: center; border: 1px solid currentColor; border-radius: 50%; }
.fp-team__teller { font: inherit; font-size: 1rem; }
.fp-team__lijn { margin: 0; border: 0; border-top: 1px solid currentColor; }
.fp-team__profiel { display: flex; gap: 1.5rem; align-items: flex-start; }
.fp-team__portret { width: min(40%, 20rem); aspect-ratio: 5 / 6; flex: 0 0 auto; border-radius: calc(var(--radius) * 1.5); object-fit: cover; object-position: top; background: var(--muted); }
.fp-team__paneelnaam { margin: 0; font: inherit; font-size: clamp(1.75rem, 4vw, 3.25rem); font-weight: 600; line-height: 1.1; }
.fp-team__paneelfunctie { margin: 0.5rem 0 0; font: inherit; font-size: 1rem; font-weight: 500; }
.fp-team__bio { display: flex; flex-direction: column; gap: 1rem; font: inherit; font-size: clamp(1rem, 1.5vw, 1.25rem); font-weight: 500; line-height: 1.4; }
.fp-team__bio p { margin: 0; }
.fp-team__links { display: flex; align-items: center; gap: 1.5rem; }
.fp-team__link { display: inline-flex; color: inherit; opacity: 0.55; text-decoration: none; transition: opacity var(--motion-default-duration) var(--motion-default); }
.fp-team__link:hover, .fp-team__link:focus-visible { opacity: 1; }
.fp-team__link:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-team__extra { width: 100%; aspect-ratio: 16 / 9; border-radius: calc(var(--radius) * 1.5); object-fit: cover; }
@media (max-width: 64rem) {
  .fp-team__paneel { width: 100%; }
  .fp-team__profiel { flex-direction: column; }
  .fp-team__portret { width: 100%; max-width: 24rem; }
}
@media (prefers-reduced-motion: reduce) {
  .fp-team__beeld, .fp-team__balk, .fp-team__plus, .fp-team__link { transition-duration: 0s; }
}