stacked-list
FC-0056stacked-listblockv1.0.0Ledenkaart: actieve leden met zoekveld bovenin; onderin een balk met drie portretten en +N die op een tik de kaart vult met de volledige lijst en eigen zoekveld. Rijen komen schuin binnen. Componeert search-field.
Actieve leden 3
Olivier de BruinOnlineProjectleider
Leo GarciaOnlineOntwerper
Mia ThomassenOnlineProjectleider
Ledenlijst7 leden%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22hsl(107%2045%25%2035%25)%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22url(%23g)%22%2F%3E%3Ccircle%20cx%3D%2244.8%22%20cy%3D%2219.2%22%20r%3D%2211.52%22%20fill%3D%22hsl(47%2045%25%2085%25%20%2F%200.6)%22%2F%3E%3C%2Fsvg%3E)
%22%2F%3E%3Cstop%20offset%3D%221%22%20stop-color%3D%22hsl(154%2045%25%2035%25)%22%2F%3E%3C%2FlinearGradient%3E%3C%2Fdefs%3E%3Crect%20width%3D%2264%22%20height%3D%2264%22%20fill%3D%22url(%23g)%22%2F%3E%3Ccircle%20cx%3D%2244.8%22%20cy%3D%2219.2%22%20r%3D%2211.52%22%20fill%3D%22hsl(94%2045%25%2085%25%20%2F%200.6)%22%2F%3E%3C%2Fsvg%3E)
+4
Tik op de balk onderin: hij groeit tot de hele kaart met de volledige lijst.
blocks/stacked-list/stacked-list.tsx
/**
* StackedList — een ledenkaart: bovenin de actieve leden met zoekveld,
* onderin een balk 'Alle leden' met drie portretten en +N; tik erop en de
* balk groeit tot hij de kaart vult, met de volledige lijst en een eigen
* zoekveld. Rijen komen schuin binnen, één voor één.
*
* De bron animeerde height/width/bottom/left/borderRadius van de balk
* (layout per frame) en had hugeicons + vier hex-badgekleuren. Hier: de
* balk wisselt van class (dicht/open) en Framer's layout-animatie vertaalt
* dat naar transforms; badges kleuren via een `roleColor` van het project
* met --muted als terugval. Componeert search-field (FC-0048).
*/
"use client";
import { useMemo, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import { usePress } from "../../primitives/ui/press";
import { SearchField } from "../../primitives/ui/search-field";
export interface Member {
id: string;
name: string;
/** "Online", "17 min geleden" — vrije tekst van het project. */
status?: string;
online?: boolean;
role?: string;
/** Kleur van het rolbadge (CSS-kleur); zonder = --muted. */
roleColor?: string;
roleIcon?: React.ReactNode;
avatar?: string;
}
export interface StackedListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
members: readonly Member[];
labels?: { active?: string; directory?: string; registered?: string; search?: string; searchAll?: string; add?: string; close?: string };
onAdd?: () => void;
onSelect?: (member: Member) => void;
}
function Portret({ m }: { m: Member }) {
return m.avatar ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="fp-stack__portret" src={m.avatar} alt="" width={48} height={48} loading="lazy" />
) : (
<span className="fp-stack__portret fp-stack__portret--letter" aria-hidden="true">{m.name.charAt(0)}</span>
);
}
function Rij({ m, onSelect }: { m: Member; onSelect?: (m: Member) => void }) {
const veer = useSpringTransition("snappy", { mass: "content" });
const { pressProps } = usePress({ onPress: () => onSelect?.(m), disabled: !onSelect });
return (
<motion.li
{...(onSelect ? pressProps : {})}
className="fp-stack__rij"
role={onSelect ? "button" : undefined}
tabIndex={onSelect ? 0 : undefined}
variants={{ dicht: { opacity: 0, x: 10, y: 15, rotate: 1 }, open: { opacity: 1, x: 0, y: 0, rotate: 0 } }}
transition={veer}
style={{ originX: 1, originY: 1 }}
>
<span className="fp-stack__portret-vak">
<Portret m={m} />
{m.online && <span aria-hidden="true" className="fp-stack__online" />}
</span>
<span className="fp-stack__wie">
<span className="fp-stack__naam">{m.name}</span>
{m.status && <span className={m.online ? "fp-stack__status fp-stack__status--online" : "fp-stack__status"}>{m.status}</span>}
</span>
{m.role && (
<span className="fp-stack__badge" style={m.roleColor ? ({ ["--fp-stack-rol" as string]: m.roleColor } as React.CSSProperties) : undefined}>
{m.roleIcon}
<span>{m.role}</span>
</span>
)}
</motion.li>
);
}
function IconKnop({ label, onPress, children, className }: { label: string; onPress?: () => void; children: React.ReactNode; className?: string }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<button {...pressProps} type="button" className={["fp-stack__icoonknop", className].filter(Boolean).join(" ")} aria-label={label} data-pressed={pressed ? "" : undefined}>
{children}
</button>
);
}
export function StackedList({ members, labels, onAdd, onSelect, className, ...rest }: StackedListProps) {
const t = { active: "Actieve leden", directory: "Ledenlijst", registered: "{n} leden", search: "Zoek een collega…", searchAll: "Zoek een lid…", add: "Lid toevoegen", close: "Sluiten", ...labels };
const reduced = useReducedMotion() ?? false;
const [open, setOpen] = useState(false);
const [zoek, setZoek] = useState("");
const [zoekAlles, setZoekAlles] = useState("");
const balk = useSpringTransition("smooth", { mass: "system" });
const fade = useSpringTransition("default", { mass: "content" });
const { pressProps: openProps } = usePress({ onPress: () => setOpen(true), disabled: open });
const filter = (q: string) => members.filter((m) => `${m.name} ${m.role ?? ""}`.toLowerCase().includes(q.trim().toLowerCase()));
const actief = useMemo(() => filter(zoek).filter((m) => m.online), [members, zoek]); // eslint-disable-line react-hooks/exhaustive-deps
const alles = useMemo(() => filter(zoekAlles), [members, zoekAlles]); // eslint-disable-line react-hooks/exhaustive-deps
const eerste = members.slice(0, 3);
return (
<div {...rest} className={["fp-stack", className].filter(Boolean).join(" ")}>
<div className="fp-stack__boven">
<div className="fp-stack__kop">
<h2 className="fp-stack__titel">{t.active} <span className="fp-stack__teller">{members.filter((m) => m.online).length}</span></h2>
{onAdd && <IconKnop label={t.add} onPress={onAdd}><svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><path d="M12 5v14M5 12h14" /></svg></IconKnop>}
</div>
<SearchField placeholder={t.search} shortcut="" value={zoek} onChange={(e) => setZoek(e.target.value)} />
<motion.ul className="fp-stack__lijst" initial={false} animate="open" variants={{ open: { transition: { staggerChildren: 0.04 } } }}>
{actief.map((m) => <Rij key={m.id} m={m} onSelect={onSelect} />)}
</motion.ul>
</div>
{/* De balk: dicht onderaan, open over de hele kaart — via class + layout-FLIP. */}
<motion.div
layout={!reduced}
transition={balk}
className={open ? "fp-stack__balk fp-stack__balk--open" : "fp-stack__balk"}
{...(open ? {} : openProps)}
role={open ? undefined : "button"}
tabIndex={open ? undefined : 0}
>
<div className="fp-stack__balk-kop">
<span className="fp-stack__balk-icoon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round"><circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" /></svg>
</span>
<motion.span layout="position" className="fp-stack__balk-tekst">
<span className="fp-stack__balk-titel">{t.directory}</span>
<span className="fp-stack__balk-sub">{t.registered.replace("{n}", String(members.length))}</span>
</motion.span>
<span className="fp-stack__balk-rechts">
{!open && (
<span className="fp-stack__stapel">
{eerste.map((m) => <span key={m.id} className="fp-stack__stapel-item"><Portret m={m} /></span>)}
{members.length > 3 && <span className="fp-stack__stapel-item fp-stack__stapel-rest">+{members.length - 3}</span>}
</span>
)}
{open && (
<IconKnop label={t.close} onPress={() => setOpen(false)} className="fp-stack__sluit">
<svg aria-hidden="true" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</IconKnop>
)}
</span>
</div>
<AnimatePresence>
{open && (
<motion.div className="fp-stack__balk-inhoud" initial={{ opacity: 0, y: -8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} transition={fade}>
<SearchField placeholder={t.searchAll} shortcut="" value={zoekAlles} onChange={(e) => setZoekAlles(e.target.value)} />
<motion.ul className="fp-stack__lijst" initial="dicht" animate="open" variants={{ open: { transition: { staggerChildren: 0.03, delayChildren: 0.1 } } }}>
{alles.map((m) => <Rij key={m.id} m={m} onSelect={onSelect} />)}
</motion.ul>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
blocks/stacked-list/stacked-list.css
/** StackedList — importeren in de app-root (registry-site: app/globals.css). */
.fp-stack {
position: relative; display: flex; flex-direction: column; overflow: hidden;
width: 100%; max-width: 27.5rem; height: 36rem;
background: var(--card); color: var(--card-foreground, var(--foreground));
border: 1px solid var(--border); border-radius: calc(var(--radius) * 3);
}
.fp-stack__boven { display: flex; flex-direction: column; gap: 1rem; padding: 2rem 2rem 6rem; overflow-y: auto; flex: 1; scrollbar-width: thin; }
.fp-stack__kop { display: flex; align-items: center; justify-content: space-between; }
.fp-stack__titel { margin: 0; display: flex; align-items: center; gap: 0.5rem; font: inherit; font-size: 1.125rem; font-weight: 600; letter-spacing: -0.01em; }
.fp-stack__teller { padding: 0.25rem 0.5rem; font-size: 0.75rem; font-weight: 400; color: var(--muted-foreground); background: var(--muted); border-radius: 999px; font-variant-numeric: tabular-nums; }
.fp-stack__icoonknop {
display: flex; align-items: center; justify-content: center; width: 2.25rem; height: 2.25rem;
appearance: none; cursor: pointer; background: transparent; color: var(--muted-foreground);
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent); border-radius: 999px;
transition: background-color var(--motion-instant-duration) var(--motion-instant), transform var(--motion-instant-duration) var(--motion-instant);
}
.fp-stack__icoonknop:hover { background: var(--muted); }
.fp-stack__icoonknop[data-pressed] { transform: scale(0.92); }
.fp-stack__icoonknop:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-stack__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; }
.fp-stack__rij { display: flex; align-items: center; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); }
.fp-stack__rij:last-child { border-bottom: 0; }
.fp-stack__rij[role="button"] { cursor: pointer; }
.fp-stack__rij:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; border-radius: var(--radius); }
.fp-stack__portret-vak { position: relative; flex-shrink: 0; }
.fp-stack__portret { display: flex; align-items: center; justify-content: center; width: 3rem; height: 3rem; border-radius: 50%; object-fit: cover; background: var(--muted); box-shadow: 0 0 0 2px var(--card); }
.fp-stack__portret--letter { font-weight: 600; color: var(--muted-foreground); }
.fp-stack__online { position: absolute; right: 0; bottom: 0; width: 0.875rem; height: 0.875rem; border-radius: 50%; background: var(--card); box-shadow: inset 0 0 0 0.1875rem var(--card), inset 0 0 0 0.5rem var(--success); }
.fp-stack__wie { display: flex; flex-direction: column; gap: 0.375rem; min-width: 0; flex: 1; }
.fp-stack__naam { font-weight: 600; letter-spacing: -0.01em; line-height: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fp-stack__status { font-size: 0.875rem; font-weight: 500; line-height: 1; color: var(--muted-foreground); }
.fp-stack__status--online { color: var(--success); }
.fp-stack__badge {
--fp-stack-rol: var(--muted-foreground);
display: inline-flex; align-items: center; gap: 0.375rem; flex-shrink: 0;
padding: 0.25rem 0.625rem; font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.02em; white-space: nowrap;
color: var(--fp-stack-rol);
background: color-mix(in srgb, var(--fp-stack-rol) 10%, var(--card));
border: 1px solid color-mix(in srgb, var(--fp-stack-rol) 30%, transparent);
border-radius: 999px;
}
/* De balk: dicht = onderaan met marge; open = de hele kaart. Alleen classes — Framer's layout doet de rest. */
.fp-stack__balk {
position: absolute; z-index: 5; display: flex; flex-direction: column; overflow: hidden;
left: 1.25rem; right: 1.25rem; bottom: 1.25rem; height: 4.25rem;
background: var(--card); border: 1px solid var(--border); border-radius: calc(var(--radius) * 1.5);
cursor: pointer;
}
.fp-stack__balk:focus-visible { outline: 2px solid var(--ring, var(--info)); outline-offset: 2px; }
.fp-stack__balk--open { left: 0.625rem; right: 0.625rem; bottom: 0.625rem; top: 0.625rem; height: auto; cursor: default; border-radius: calc(var(--radius) * 2.5); }
.fp-stack__balk-kop { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; height: 4.25rem; flex-shrink: 0; padding: 0 0.75rem; }
.fp-stack__balk--open .fp-stack__balk-kop { border-bottom: 1px solid color-mix(in srgb, var(--border) 40%, transparent); }
.fp-stack__balk-icoon { display: flex; align-items: center; justify-content: center; width: 2.75rem; height: 2.75rem; flex-shrink: 0; color: var(--muted-foreground); background: var(--background); border: 1px solid var(--border); border-radius: var(--radius); }
.fp-stack__balk-tekst { display: flex; flex-direction: column; gap: 0.25rem; flex: 1; min-width: 0; }
.fp-stack__balk-titel { font-weight: 500; letter-spacing: -0.01em; line-height: 1; }
.fp-stack__balk-sub { font-size: 0.75rem; line-height: 1; color: var(--muted-foreground); }
.fp-stack__balk-rechts { display: flex; align-items: center; }
.fp-stack__stapel { display: flex; }
.fp-stack__stapel-item { display: flex; margin-left: -0.75rem; }
.fp-stack__stapel-item:first-child { margin-left: 0; }
.fp-stack__stapel-item .fp-stack__portret { width: 2.5rem; height: 2.5rem; box-shadow: 0 0 0 1px var(--card); }
.fp-stack__stapel-rest { align-items: center; justify-content: center; width: 2.5rem; height: 2.5rem; border-radius: 50%; background: var(--muted); font-size: 0.875rem; color: var(--muted-foreground); box-shadow: 0 0 0 1px var(--card); }
.fp-stack__sluit { border-color: transparent; background: color-mix(in srgb, var(--muted) 60%, transparent); border-radius: var(--radius); }
.fp-stack__balk-inhoud { display: flex; flex-direction: column; gap: 0.5rem; padding: 1rem 1.5rem; flex: 1; overflow-y: auto; scrollbar-width: thin; }