Stapel kaarten die openwaaiert tot een groot beeld en een rail van miniaturen: tik op de stapel en de kaarten vliegen naar hun plek; tik op een miniatuur en die ruilt met het grote beeld — dezelfde elementen, verplaatst, nooit opnieuw gemount. Links titel, meta, tekst en een bijschrift per kaart; teller '01 / 06'. Smal wordt het een gestapelde kolom. Escape sluit, pijltjes bladeren. Componeert motion en use-press.
01 / 05
Campagnebeelden
Vijf beelden uit een seizoenscampagne. Tik op één om hem naar voren te halen.
Laat licht op de kust, vanaf het klifpad.
Tik op de stapel om hem uit te waaieren, dan op een miniatuur om die met het grote beeld te ruilen. Escape sluit, pijltjes bladeren.
blocks/apps-ii/flip-gallery.tsx
/**
* FlipGallery — een stapel kaarten die openwaaiert tot een groot beeld en
* een rail van miniaturen: tik op de stapel en de kaarten vliegen naar hun
* plek; tik op een miniatuur en die ruilt met het grote beeld — dezelfde
* elementen, gemeten en verplaatst, nooit opnieuw gemount. Links een titel,
* meta, tekst en een bijschrift dat per kaart wisselt; teller '01 / 06'.
* Smal (< 48rem) wordt het een gestapelde kolom; onder reduced motion is elke
* wissel direct. Escape sluit; pijltjes bladeren. Composeert motion + use-press.
*
* Geoogst uit 21st.dev Gsap Card Flip (hyperiux): GSAP Flip → framer
* `layout` (FLIP op dezelfde elementen) op de smooth-token, SplitText-
* regelonthulling van het bijschrift → één slide/fade per bijschrift, de
* vaste #e9e9e7/#111111/#8a8a86 → --background/--foreground/--muted-
* foreground, `rounded` in px → --radius-lg, Unsplash-defaults → verplichte
* `items`, vw-lettergroottes → typeschaal, onClick → usePress per kaart.
*/
"use client";
import { useCallback, useEffect, useLayoutEffect, useRef, 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";
export interface FlipGalleryItem { id: string; image: string; alt?: string; caption?: string }
export interface FlipGalleryProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title" | "children"> {
items: readonly FlipGalleryItem[];
title?: React.ReactNode;
meta?: React.ReactNode;
description?: React.ReactNode;
thumbWidth?: number;
thumbHeight?: number;
thumbGap?: number;
heroWidth?: number;
heroHeight?: number;
stackOffsetX?: number;
stackOffsetY?: number;
/** Draai per kaart in de gesloten stapel, graden. */
stackRotation?: number;
showCounter?: boolean;
/** Hoogte als CSS-lengte. Default 100vh. */
height?: string;
openLabel?: string;
onClose?: () => void;
}
const useIso = typeof window === "undefined" ? useEffect : useLayoutEffect;
/** Eén kaart: de motion.div doet de FLIP (layout), de knop erin het indrukken (usePress). */
function Kaart({ item, index, style, rotate, laag, label, actief, onPress, tab, veer }: { item: FlipGalleryItem; index: number; style: React.CSSProperties; rotate: number; laag: number; label: string; actief: boolean; onPress: () => void; tab: number; veer: ReturnType<typeof useSpringTransition> }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<motion.div layout className="fp-flipgal__kaart" data-index={index} style={{ ...style, zIndex: laag, rotate }} transition={veer}>
<button {...pressProps} type="button" aria-label={label} aria-current={actief || undefined} tabIndex={tab} className="fp-flipgal__kaartknop" data-pressed={pressed ? "" : undefined}>
{/* eslint-disable-next-line @next/next/no-img-element -- beelden van het project */}
<img src={item.image} alt={item.alt ?? ""} draggable={false} />
</button>
</motion.div>
);
}
export function FlipGallery({ items, title, meta, description, thumbWidth = 108, thumbHeight = 120, thumbGap = 12, heroWidth = 530, heroHeight = 670, stackOffsetX = 3, stackOffsetY = 9, stackRotation = 0, showCounter = true, height = "100vh", openLabel = "Galerie openen", onClose, className, style, ...rest }: FlipGalleryProps) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
const tekstVeer = useSpringTransition("default");
const [order, setOrder] = useState<number[]>(() => items.map((_, i) => i));
const [opened, setOpened] = useState(false);
const [stageWidth, setStageWidth] = useState(0);
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => { setOrder(items.map((_, i) => i)); }, [items]);
useIso(() => {
const el = rootRef.current;
if (!el) return;
const measure = () => setStageWidth(el.clientWidth);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, []);
const isMobile = stageWidth > 0 && stageWidth < 768;
const isNarrow = stageWidth > 0 && stageWidth < 900;
const railCount = Math.max(items.length - 1, 0);
const scale = isNarrow ? Math.min(1, stageWidth / 900) : 1;
const tW = thumbWidth * scale, tH = thumbHeight * scale, gap = thumbGap * scale;
const hW = Math.min(heroWidth * scale, stageWidth * 0.46);
const hH = heroHeight * (hW / heroWidth || 1);
const railX = Math.max(24, stageWidth * 0.045);
const heroX = stageWidth - hW - railX;
const stackWidth = Math.min(215 * scale, stageWidth * 0.42);
const stackHeight = stackWidth * 1.5;
const slotBox = useCallback((slot: number) => {
if (slot === 0) return { x: heroX, y: 0, w: hW, h: hH, z: items.length + 1 };
const railHeight = railCount * tH + (railCount - 1) * gap;
const top = -railHeight / 2 + (slot - 1) * (tH + gap);
return { x: railX, y: top + tH / 2, w: tW, h: tH, z: items.length - slot };
}, [heroX, hW, hH, railX, tW, tH, gap, railCount, items.length]);
const select = useCallback((itemIndex: number) => {
if (itemIndex === order[0]) return;
setOrder((prev) => { const next = [...prev]; const from = next.indexOf(itemIndex); next[from] = next[0]!; next[0] = itemIndex; return next; });
}, [order]);
const open = useCallback(() => { if (!opened && stageWidth > 0) setOpened(true); }, [opened, stageWidth]);
const close = useCallback(() => { if (!opened) return; setOpened(false); onClose?.(); }, [opened, onClose]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") { close(); return; }
if (!opened || items.length < 2) return;
if (e.key === "ArrowDown" || e.key === "ArrowRight") select(order[1]!);
else if (e.key === "ArrowUp" || e.key === "ArrowLeft") select(order[order.length - 1]!);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [order, select, close, opened, items.length]);
if (items.length === 0) return null;
const heroIndex = order[0]!;
const hero = items[heroIndex];
const teller = `${String(heroIndex + 1).padStart(2, "0")}`;
const totaal = String(items.length).padStart(2, "0");
const bijschrift = hero?.caption;
// Posities als left/top/width/height (framer's layout doet de FLIP); de draai van de stapel apart als rotate.
const kaartStijl = (itemIndex: number): { stijl: React.CSSProperties; rotate: number } => {
const slot = order.indexOf(itemIndex);
if (!opened) {
return { stijl: { position: "absolute", left: stageWidth / 2 - stackWidth / 2 + slot * stackOffsetX, top: `calc(50% - ${stackHeight / 2}px + ${slot * stackOffsetY}px)`, width: stackWidth, height: stackHeight }, rotate: slot * stackRotation };
}
const box = slotBox(slot);
return { stijl: { position: "absolute", left: box.x, top: `calc(50% - ${box.h / 2}px + ${box.y}px)`, width: box.w, height: box.h }, rotate: 0 };
};
const laagVan = (itemIndex: number) => { const slot = order.indexOf(itemIndex); return opened ? slotBox(slot).z : items.length - slot; };
// Als element, niet als inner component: anders remount AnimatePresence bij elke render.
const bijschriftNode = (
<div className="fp-flipgal__bijschrift">
<AnimatePresence mode="wait" initial={false}>
<motion.p key={bijschrift ?? "leeg"} initial={reduced ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? undefined : { opacity: 0, y: -8 }} transition={tekstVeer}>{bijschrift}</motion.p>
</AnimatePresence>
</div>
);
if (isMobile) {
return (
<div {...rest} ref={rootRef} className={["fp-flipgal fp-flipgal--mobiel", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-flipgal-h" as string]: height } as React.CSSProperties}>
{showCounter && <p className="fp-flipgal__teller"><strong>{teller}</strong><span> / {totaal}</span></p>}
{hero && (
<div className="fp-flipgal__held">
{/* eslint-disable-next-line @next/next/no-img-element -- beelden van het project */}
<img src={hero.image} alt={hero.alt ?? ""} draggable={false} />
</div>
)}
<div className="fp-flipgal__tekst">
{title && <h2 className="fp-flipgal__titel">{title}</h2>}
{meta && <p className="fp-flipgal__meta">{meta}</p>}
{description && <p className="fp-flipgal__omschrijving">{description}</p>}
{bijschriftNode}
</div>
<div className="fp-flipgal__raster">
{order.slice(1).map((itemIndex) => {
const it = items[itemIndex];
if (!it) return null;
return <MiniKaart key={it.id} item={it} onPress={() => select(itemIndex)} />;
})}
</div>
</div>
);
}
return (
<div {...rest} ref={rootRef} className={["fp-flipgal", opened ? "fp-flipgal--open" : "", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-flipgal-h" as string]: height } as React.CSSProperties}>
{showCounter && <p className="fp-flipgal__teller fp-flipgal__chroom"><strong>{teller}</strong><span> / {totaal}</span></p>}
<div className="fp-flipgal__podium" onClick={(e) => { if (e.target === e.currentTarget) close(); }}>
<div className={["fp-flipgal__paneel fp-flipgal__chroom", isNarrow ? "fp-flipgal__paneel--verborgen" : ""].filter(Boolean).join(" ")} style={{ left: railX + tW + Math.max(48, stageWidth * 0.06) }}>
{title && <h2 className="fp-flipgal__titel">{title}</h2>}
{meta && <p className="fp-flipgal__meta">{meta}</p>}
{description && <p className="fp-flipgal__omschrijving">{description}</p>}
{bijschriftNode}
</div>
{items.map((it, itemIndex) => {
const isHero = itemIndex === heroIndex;
const { stijl, rotate } = kaartStijl(itemIndex);
return (
<Kaart
key={it.id}
item={it}
index={itemIndex}
style={stijl}
rotate={rotate}
laag={laagVan(itemIndex)}
label={opened ? (it.alt ?? it.caption ?? `Beeld ${itemIndex + 1}`) : `${openLabel} — ${items.length} beelden`}
actief={opened && isHero}
onPress={() => (opened ? select(itemIndex) : open())}
tab={!opened ? (itemIndex === order[0] ? 0 : -1) : isHero ? -1 : 0}
veer={reduced ? { duration: 0 } : veer}
/>
);
})}
</div>
</div>
);
}
function MiniKaart({ item, onPress }: { item: FlipGalleryItem; onPress: () => void }) {
const { pressed, pressProps } = usePress({ onPress });
return (
<button {...pressProps} type="button" className="fp-flipgal__mini" aria-label={item.alt ?? item.caption ?? "Beeld"} data-pressed={pressed ? "" : undefined}>
{/* eslint-disable-next-line @next/next/no-img-element -- beelden van het project */}
<img src={item.image} alt={item.alt ?? ""} draggable={false} />
</button>
);
}
blocks/apps-ii/apps-ii.css
/**
* Apps II — importeren in de app-root (registry-site: app/globals.css).
* Token-contract 2: widget-grid, import-patchbay, clip-showcase, flip-gallery.
*/
/* ── widget-grid ─────────────────────────────────────────────────────────── */
.fp-widgets { position: relative; width: 100%; }
.fp-widgets__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-widgets__raster { display: grid; width: 100%; }
.fp-widgets__widget { position: relative; min-width: 0; border-radius: var(--radius-lg); outline: none; }
.fp-widgets__widget:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-widgets__widget--sleepbaar { cursor: grab; touch-action: pan-y pinch-zoom; user-select: none; -webkit-touch-callout: none; }
.fp-widgets__widget--sleepbaar:active { cursor: grabbing; }
.fp-widgets__widget a, .fp-widgets__widget img { -webkit-user-drag: none; }
/* De schaduw van het optillen: verf, via een klasse — framer doet alleen de schaal (één motor per property). */
.fp-widgets__vlak {
position: relative; isolation: isolate; display: flex; flex-direction: column; width: 100%; height: 100%; overflow: hidden;
border-radius: var(--radius-lg); background: var(--card); color: var(--card-foreground, var(--foreground));
box-shadow: inset 0 0 0 var(--border-width) var(--border), 0 1px 2px color-mix(in srgb, var(--foreground) 12%, transparent);
clip-path: inset(0 round var(--radius-lg));
transition: box-shadow var(--motion-default-duration) var(--motion-default);
}
.fp-widgets__widget[data-opgetild] .fp-widgets__vlak { box-shadow: inset 0 0 0 var(--border-width) var(--border), var(--shadow-overlay); }
.fp-widgets__vlak--geland { box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--foreground) 40%, transparent); }
/* ── import-patchbay ─────────────────────────────────────────────────────── */
.fp-patch { width: 100%; overflow: hidden; border-radius: var(--radius-lg); border: var(--border-width) solid var(--border); background: var(--card); color: var(--card-foreground, var(--foreground)); box-shadow: 0 1px 2px color-mix(in srgb, var(--foreground) 6%, transparent); }
.fp-patch__sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.fp-patch__mono { font-variant-numeric: tabular-nums; letter-spacing: 0.01em; }
.fp-patch__kop { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); padding: var(--space-sm) var(--space-md); border-bottom: var(--border-width) solid var(--border); }
.fp-patch__bestand { display: flex; align-items: center; gap: var(--space-xs); min-width: 0; }
.fp-patch__type { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 2rem; height: 2rem; border-radius: var(--radius-sm); border: var(--border-width) solid var(--border); background: var(--muted); color: var(--foreground); font-size: var(--text-label); font-weight: 500; }
.fp-patch__bestandsnaam { display: grid; min-width: 0; }
.fp-patch__naam { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body-sm); font-weight: 500; color: var(--foreground); }
.fp-patch__meta { font-size: var(--text-label); color: var(--muted-foreground); font-variant-numeric: tabular-nums; }
.fp-patch__auto { display: inline-flex; align-items: center; gap: var(--space-xs); height: 2.75rem; border-radius: 999px; padding: 0 var(--space-md); }
.fp-patch__auto > svg { width: 0.875rem; height: 0.875rem; color: var(--primary); }
.fp-patch__bord { container-type: inline-size; padding: var(--space-md) var(--space-md) var(--space-2xs); }
.fp-patch__koppen { display: flex; align-items: center; margin-bottom: var(--space-xs); font-size: var(--text-label); font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted-foreground); }
.fp-patch__kopje { flex: 1; min-width: 0; }
.fp-patch__goot-ruimte { flex: 0 0 auto; }
.fp-patch__leeg { margin: 0; padding: var(--space-xl) var(--space-md); border: var(--border-width) dashed var(--border); border-radius: var(--radius); text-align: center; font-size: var(--text-body-sm); color: var(--muted-foreground); }
.fp-patch__rijen { display: flex; align-items: flex-start; }
.fp-patch__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex: 1; min-width: 0; flex-direction: column; }
.fp-patch__jack {
position: relative; display: flex; align-items: center; gap: var(--space-xs); width: 100%; height: 100%;
border: var(--border-width) solid var(--border); border-radius: var(--radius); background: var(--background); color: var(--foreground);
cursor: pointer; font: inherit; text-align: left;
transition: border-color var(--motion-instant-duration) var(--motion-instant), background var(--motion-instant-duration) var(--motion-instant);
}
.fp-patch__jack--kolom { padding: 0 var(--space-lg) 0 var(--space-sm); }
.fp-patch__jack--veld { padding: 0 var(--space-sm) 0 var(--space-lg); }
.fp-patch__jack:hover { border-color: color-mix(in srgb, var(--foreground) 25%, transparent); }
.fp-patch__jack:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-patch__jack[data-gekoppeld] { border-color: color-mix(in srgb, var(--primary) 40%, transparent); }
.fp-patch__jack[data-vast] { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 10%, transparent); }
.fp-patch__jack[data-ontbreekt] { border-style: dashed; border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.fp-patch__jack[data-kandidaat] { border-color: var(--primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 40%, transparent); }
.fp-patch__jack-tekst { flex: 1; min-width: 0; display: grid; }
.fp-patch__jack-naam { display: flex; align-items: center; gap: var(--space-2xs); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body-sm); font-weight: 500; color: var(--foreground); }
.fp-patch__afkap { overflow: hidden; text-overflow: ellipsis; }
.fp-patch__ster { flex: 0 0 auto; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__jack-sub { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__soort { flex: 0 0 auto; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__soort--lang { display: none; }
@container (min-width: 420px) { .fp-patch__soort--lang { display: inline; } .fp-patch__soort--kort { display: none; } }
.fp-patch__fout { flex: 0 0 auto; font-size: var(--text-label); color: var(--danger); font-variant-numeric: tabular-nums; }
.fp-patch__badge { flex: 0 0 auto; padding: 0.125rem var(--space-xs); border-radius: 999px; border: var(--border-width) solid var(--border); background: var(--background); font-size: var(--text-label); font-weight: 500; color: var(--muted-foreground); }
/* De plug: zijn middelpunt ligt precies op de rand van de goot. */
.fp-patch__plug { position: absolute; top: 50%; display: flex; align-items: center; justify-content: center; width: 0.875rem; height: 0.875rem; border-radius: 50%; border: 2px solid var(--border); background: var(--background); transform: translateY(-50%); }
.fp-patch__plug--rechts { right: -7px; }
.fp-patch__plug--links { left: -7px; }
.fp-patch__plug--actief { border-color: var(--primary); }
.fp-patch__plug--ontbreekt { border-color: color-mix(in srgb, var(--danger) 60%, transparent); }
.fp-patch__plug-kern { display: block; width: 0.375rem; height: 0.375rem; border-radius: 50%; background: var(--primary); }
.fp-patch__plug-kern--zit { animation: fp-patch-zit var(--motion-instant-duration) var(--motion-instant); }
@keyframes fp-patch-zit { 0% { transform: scale(1); } 45% { transform: scale(1.12); } 100% { transform: scale(1); } }
.fp-patch__goot { flex: 0 0 auto; overflow: visible; }
.fp-patch__kabel { stroke: var(--primary); }
.fp-patch__kabel--gok { stroke: var(--muted-foreground); }
.fp-patch__kabel--tekent { animation: fp-patch-teken var(--motion-default-duration) var(--motion-default); }
@keyframes fp-patch-teken { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
@media (prefers-reduced-motion: reduce) { .fp-patch__kabel--tekent, .fp-patch__plug-kern--zit { animation: none; } }
.fp-patch__voet { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--space-sm); margin-top: var(--space-sm); padding: var(--space-sm) var(--space-md); border-top: var(--border-width) solid var(--border); }
.fp-patch__oordeel { min-width: 0; margin: 0; font-size: var(--text-label); color: var(--muted-foreground); }
.fp-patch__teller { font-weight: 500; color: var(--foreground); font-variant-numeric: tabular-nums; }
.fp-patch__punt { margin: 0 var(--space-2xs); }
.fp-patch__oordeel--rood { color: var(--danger); }
.fp-patch__bevestig { height: 2.75rem; border-radius: 999px; padding: 0 var(--space-lg); }
.fp-patch__bevestig--geblokkeerd { background: var(--muted); color: var(--foreground); cursor: not-allowed; }
/* ── clip-showcase ───────────────────────────────────────────────────────── */
.fp-clipshow { display: flex; flex-direction: column; align-items: center; justify-content: space-between; gap: var(--space-2xl); width: 100%; min-height: var(--fp-clipshow-h, 100vh); padding: var(--space-xl); box-sizing: border-box; overflow: hidden; background: var(--background); color: var(--foreground); }
@media (min-width: 48rem) { .fp-clipshow { flex-direction: row; padding: var(--space-2xl); } }
.fp-clipshow__menu { position: relative; z-index: 2; width: 100%; }
@media (min-width: 48rem) { .fp-clipshow__menu { width: 50%; } }
.fp-clipshow__lijst { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xl); }
.fp-clipshow__knop { display: flex; align-items: flex-start; gap: var(--space-lg); padding: 0; border: 0; background: transparent; color: inherit; cursor: pointer; font: inherit; text-align: left; text-decoration: none; }
.fp-clipshow__knop:focus-visible { outline: 2px solid var(--ring); outline-offset: 4px; border-radius: var(--radius-sm); }
.fp-clipshow__nummer { margin-top: var(--space-xs); font-size: var(--text-h3); font-weight: 700; color: var(--muted-foreground); transform: scale(1); transition: color var(--motion-default-duration) var(--motion-default), transform var(--motion-default-duration) var(--motion-default); }
.fp-clipshow__item[data-actief] .fp-clipshow__nummer { color: var(--primary); transform: scale(1.1); }
/* Inactieve titels: alleen een contourlijn in de tekstkleur; de actieve is gevuld en schuift naar rechts. */
.fp-clipshow__titel { font-size: var(--text-display); line-height: 0.85; font-weight: 900; letter-spacing: -0.05em; text-transform: uppercase; color: transparent; -webkit-text-stroke: 1.5px color-mix(in srgb, var(--foreground) 40%, transparent); opacity: 0.6; transform: translateX(0); transition: color var(--motion-smooth-duration) var(--motion-smooth), opacity var(--motion-smooth-duration) var(--motion-smooth), transform var(--motion-smooth-duration) var(--motion-smooth); }
.fp-clipshow__item[data-actief] .fp-clipshow__titel { color: var(--foreground); -webkit-text-stroke: 0; opacity: 1; transform: translateX(var(--space-md)); }
.fp-clipshow__podium { position: relative; display: flex; align-items: center; justify-content: center; width: 100%; }
@media (min-width: 48rem) { .fp-clipshow__podium { width: 50%; } }
.fp-clipshow__gloed { position: absolute; width: 120%; height: 120%; border-radius: 50%; background: color-mix(in srgb, var(--primary) 10%, transparent); filter: blur(120px); pointer-events: none; }
.fp-clipshow__doek { position: relative; z-index: 1; width: 100%; max-width: 31rem; height: auto; filter: drop-shadow(var(--shadow-overlay)); }
/* ── flip-gallery ────────────────────────────────────────────────────────── */
.fp-flipgal { position: relative; width: 100%; min-height: var(--fp-flipgal-h, 100vh); overflow: hidden; background: var(--background); color: var(--foreground); }
.fp-flipgal__chroom { opacity: 0; pointer-events: none; transition: opacity var(--motion-smooth-duration) var(--motion-smooth); }
.fp-flipgal--open .fp-flipgal__chroom { opacity: 1; pointer-events: auto; }
.fp-flipgal__teller { position: absolute; top: var(--space-lg); left: var(--space-lg); z-index: 60; margin: 0; font-size: var(--text-label); letter-spacing: 0.02em; font-variant-numeric: tabular-nums; }
.fp-flipgal__teller span { color: var(--muted-foreground); }
.fp-flipgal__podium { position: relative; width: 100%; min-height: var(--fp-flipgal-h, 100vh); cursor: default; }
.fp-flipgal--open .fp-flipgal__podium { cursor: pointer; }
.fp-flipgal__paneel { position: absolute; top: 50%; z-index: 30; max-width: 22rem; transform: translateY(-50%); pointer-events: none; }
.fp-flipgal__paneel--verborgen { display: none; }
.fp-flipgal__titel { margin: 0; font: inherit; font-size: var(--text-h2); line-height: 1.05; font-weight: 400; letter-spacing: -0.02em; }
.fp-flipgal__meta { margin: var(--space-xs) 0 var(--space-lg); font-size: var(--text-label); color: var(--muted-foreground); }
.fp-flipgal__omschrijving { margin: 0; font-size: var(--text-body-sm); line-height: 1.65; }
.fp-flipgal__bijschrift { position: relative; min-height: calc(var(--text-body-sm) * 1.3 * 2); margin-top: var(--space-lg); }
.fp-flipgal__bijschrift p { margin: 0; width: 80%; font-size: var(--text-body-sm); line-height: 1.3; }
.fp-flipgal__kaart { border-radius: var(--radius-lg); overflow: hidden; background: var(--muted); box-shadow: var(--shadow-overlay); will-change: transform; }
.fp-flipgal__kaartknop { display: block; width: 100%; height: 100%; padding: 0; border: 0; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.fp-flipgal__kaartknop:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.fp-flipgal__kaartknop[data-pressed] { transform: scale(0.98); }
.fp-flipgal__kaartknop > img { display: block; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.fp-flipgal--open .fp-flipgal__kaart[data-index] .fp-flipgal__kaartknop[aria-current] { cursor: default; }
/* Mobiel: gestapelde kolom met groot beeld, tekst en een raster van miniaturen. */
.fp-flipgal--mobiel { display: flex; flex-direction: column; gap: var(--space-lg); padding: var(--space-xl) var(--space-md); box-sizing: border-box; overflow-y: auto; }
.fp-flipgal--mobiel .fp-flipgal__teller { position: static; }
.fp-flipgal__held { position: relative; flex: 1; min-height: 45vh; border-radius: var(--radius-lg); overflow: hidden; background: var(--muted); box-shadow: var(--shadow-overlay); }
.fp-flipgal__held > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.fp-flipgal__raster { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-sm); }
.fp-flipgal__mini { position: relative; aspect-ratio: 4 / 5; width: 100%; padding: 0; border: 0; border-radius: var(--radius); overflow: hidden; background: var(--muted); cursor: pointer; -webkit-tap-highlight-color: transparent; }
.fp-flipgal__mini:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-flipgal__mini[data-pressed] { transform: scale(0.97); }
.fp-flipgal__mini > img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }