Menu van genummerde, kolossale titels links en rechts één beeld dat door een wisselende uitsnede heen leeft: het beeld zit in een SVG-clip van vormen (balken, blokken, 3×3-raster) die in willekeur opengaan, ademen en sluiten, in een lus. Op hover of focus van een titel wisselen beeld en uitsnede; de actieve titel schuift naar rechts en het nummer kleurt in de merkkleur.
Kom op een titel: het beeld en de uitsnede wisselen; de vormen gaan open, ademen en sluiten in een lus.
blocks/apps-ii/clip-showcase.tsx
/**
* ClipShowcase — een menu van genummerde, kolossale titels links en rechts
* één beeld dat door een wisselende uitsnede heen leeft: het beeld zit in
* een SVG-clip van vormen (balken, blokken, een 3×3 raster) die in willekeur
* opengaan, even ademen en weer sluiten, in een lus. Op hover van een titel
* wisselen beeld en uitsnede; de actieve titel schuift iets naar rechts en
* het nummer kleurt in de merkkleur. Composeert motion.
*
* Geoogst uit 21st.dev Connoisseur Stack Interactor (hardikkashiyani):
* GSAP-timeline (expo.out / sine.inOut / expo.in, stagger random/center/
* edges) → framer keyframes per vorm met een vaste pseudo-random vertraging
* (geen hydration-verschil) op de smooth-token, text-orange-500 en de
* oranje gloed → --primary, text-stroke #52525b → color-mix van
* --foreground, drie vaste gerechten → `items`, de drie clip-vormen →
* `clip: 'bars' | 'blocks' | 'grid'` per item, onMouseEnter → pointer/focus.
*/
"use client";
import { useId, useState } from "react";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
export type ClipShape = "bars" | "blocks" | "grid";
export interface ClipShowcaseItem {
id: string;
/** '01', '02' … */
number: string;
/** Titel; een spatie wordt een regelbreuk. */
title: string;
image: string;
clip?: ClipShape;
href?: string;
}
export interface ClipShowcaseProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "onSelect"> {
items: readonly ClipShowcaseItem[];
onSelect?: (item: ClipShowcaseItem) => void;
linkComponent?: React.ElementType;
}
/** De vormen per uitsnede, in een 500×500-viewBox. Een clipPath kent alleen shapes (geen <g>), dus elke vorm animeert zelf. */
type Vorm = { d: string } | { x: number; y: number; w: number; h: number; rx: number };
const VORMEN: Record<ClipShape, Vorm[]> = {
bars: [
{ d: "M480.6,235H19.4c-6,0-10.8-4.9-10.8-10.8v-9.5c0-6,4.9-10.8,10.8-10.8h461.1c6,0,10.8,4.9,10.8,10.8v9.5C491.4,230.2,486.6,235,480.6,235z" },
{ d: "M483.1,362.4H16.9c-4.6,0-8.3-3.7-8.3-8.3v-1.8c0-4.6,3.7-8.3,8.3-8.3h466.1c4.6,0,8.3,3.7,8.3,8.3v1.8C491.4,358.7,487.7,362.4,483.1,362.4z" },
{ d: "M460.3,336.3H39.7c-17.2,0-31.1-13.9-31.1-31.1v-31.5c0-17.2,13.9-31.1,31.1-31.1h420.7c17.2,0,31.1,13.9,31.1,31.1v31.5C491.4,322.4,477.5,336.3,460.3,336.3z" },
{ d: "M459.2,196.2H40.8v-35c0-47.5,38.5-86,86-86h246.5c47.5,0,86,38.5,86,86V196.2z" },
{ d: "M441.9,424.9H58.1c-9.6,0-17.3-7.8-17.3-17.3v-37.4h418.5v37.4C459.2,417.1,451.5,424.9,441.9,424.9z" },
],
blocks: [
{ x: 20, y: 20, w: 200, h: 280, rx: 12 }, { x: 20, y: 320, w: 200, h: 160, rx: 12 }, { x: 240, y: 20, w: 240, h: 140, rx: 12 },
{ x: 240, y: 180, w: 110, h: 160, rx: 12 }, { x: 370, y: 180, w: 110, h: 160, rx: 12 }, { x: 240, y: 360, w: 240, h: 120, rx: 12 },
],
grid: Array.from({ length: 9 }, (_, i) => ({ x: (i % 3) * 160 + 20, y: Math.floor(i / 3) * 160 + 20, w: 140, h: 140, rx: 4 })),
};
/** Vaste pseudo-random vertraging per vorm: server en client animeren hetzelfde. */
const vertraging = (i: number, n: number) => ((i * 7919) % n) / n * 0.4;
export function ClipShowcase({ items, onSelect, linkComponent: Link = "a", className, ...rest }: ClipShowcaseProps) {
const [actief, setActief] = useState(0);
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth");
const item = items[actief] ?? items[0];
const clip = item?.clip ?? (["bars", "blocks", "grid"] as const)[actief % 3]!;
const vormen = VORMEN[clip];
const clipId = `fp-clipshow-${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
const kies = (i: number) => { if (i === actief) return; setActief(i); onSelect?.(items[i]!); };
return (
<section {...rest} className={["fp-clipshow", className].filter(Boolean).join(" ")}>
<nav className="fp-clipshow__menu" aria-label="Keuze">
<ul className="fp-clipshow__lijst">
{items.map((it, i) => {
const [eerste, ...rest2] = it.title.split(" ");
const Tag: React.ElementType = it.href ? Link : "button";
return (
<li key={it.id} className="fp-clipshow__item" data-actief={i === actief ? "" : undefined}>
<Tag {...(it.href ? { href: it.href } : { type: "button" })} className="fp-clipshow__knop" onPointerEnter={() => kies(i)} onFocus={() => kies(i)}>
<span className="fp-clipshow__nummer">{it.number}</span>
<span className="fp-clipshow__titel">{eerste}{rest2.length > 0 && <><br />{rest2.join(" ")}</>}</span>
</Tag>
</li>
);
})}
</ul>
</nav>
<div className="fp-clipshow__podium">
<span className="fp-clipshow__gloed" aria-hidden="true" />
<svg key={`${item?.id}-${clip}`} viewBox="0 0 500 500" className="fp-clipshow__doek" aria-hidden="true">
<defs>
<clipPath id={clipId}>
{vormen.map((v, i) => {
const beweging = {
style: { transformOrigin: "50% 50%", transformBox: "fill-box" } as React.CSSProperties,
initial: reduced ? { scale: 1 } : { scale: 0 },
animate: reduced ? { scale: 1 } : { scale: [0, 1, 1.05, 1, 0] },
transition: reduced ? { duration: 0 } : { duration: 5, times: [0, 0.16, 0.46, 0.76, 1], repeat: Infinity, repeatDelay: 1, delay: vertraging(i, vormen.length), ease: "easeInOut" as const },
};
return "d" in v ? <motion.path key={i} d={v.d} {...beweging} /> : <motion.rect key={i} x={v.x} y={v.y} width={v.w} height={v.h} rx={v.rx} {...beweging} />;
})}
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
<motion.image key={item?.image} href={item?.image} width="500" height="500" preserveAspectRatio="xMidYMid slice" initial={reduced ? false : { opacity: 0 }} animate={{ opacity: 1 }} transition={veer} />
</g>
</svg>
</div>
</section>
);
}
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; }