Raster van widgets dat je herschikt door te slepen: volgorde + maat per widget (klein, breed, hoog, groot), een exacte tegelaar maakt er bij elk kolomaantal een gatloze rechthoek van, framer animeert elke verandering. Muis/pen slepen direct, touch via lang indrukken, toetsenbord met Alt + pijltjes. Composeert motion.
Sleep een widget (touch: ingedrukt houden; toetsenbord: Alt + pijltjes). Volgorde: omzet · status · bezoek · fouten · laatste · score · kanalen · uptime.
blocks/apps-ii/widget-grid.tsx
/**
* WidgetGrid — een raster van widgets dat je herschikt door te slepen. De
* indeling is een volgorde plus een maat per widget (sm 1×1, wide 2×1,
* tall 1×2, lg 2×2); een exacte tegelaar maakt daar bij elk kolomaantal een
* gatloze rechthoek van, en framer animeert elke verandering (layout).
* Muis en pen slepen direct; touch via lang indrukken (de pagina blijft
* scrollbaar); toetsenbord: focus een widget, Alt + pijltjes.
*
* Geoogst uit 21st.dev Draggable Widget Grid (carolinaraulino): motion/react
* → framer-motion, spring visualDuration/bounce (0.32 > plafond) → de
* motion-tokens (smooth voor de layout, pop voor het optillen), de twee
* rgba-schaduwen via framer → CSS-klasse met --shadow-overlay (verf, één
* motor), Tailwind bg-card/ring-border → fp-widgets-CSS, --widget-radius in
* px → --radius-lg, de sr-only-hint → `hint`-prop (NL). Tegelaar,
* kandidaten-keuze en drag-logica zijn ongewijzigd.
*/
"use client";
import { memo, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type * as React from "react";
import { MotionConfig, motion, useDragControls } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
/** Kolom × rij: sm 1×1, wide 2×1, tall 1×2, lg 2×2. */
export type WidgetSize = "sm" | "wide" | "tall" | "lg";
export interface WidgetItem {
id: string;
size: WidgetSize;
/** Toegankelijke naam. */
label?: string;
}
export interface WidgetGridProps {
/** Beginindeling; het raster beheert daarna de volgorde. */
items: WidgetItem[];
/** Nieuwe volgorde na een drop of toetsenbord-zet. */
onChange?: (items: WidgetItem[]) => void;
renderItem?: (item: WidgetItem, size: WidgetSize) => React.ReactNode;
editable?: boolean;
maxColumns?: number;
/** Doelbreedte van een cel in px; het kolomaantal volgt eruit. */
cellSize?: number;
/** Ruimte tussen widgets in px. */
gap?: number;
/** Sr-only uitleg voor hulptechnologie. */
hint?: string;
className?: string;
}
const SPANS: { [K in WidgetSize]: { col: number; row: number } } = { sm: { col: 1, row: 1 }, wide: { col: 2, row: 1 }, tall: { col: 1, row: 2 }, lg: { col: 2, row: 2 } };
const SIZE_LABELS: { [K in WidgetSize]: string } = { sm: "Klein", wide: "Breed", tall: "Hoog", lg: "Groot" };
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
function sizeOf(w: number, h: number): WidgetSize {
if (w >= 2 && h >= 2) return "lg";
if (w >= 2) return "wide";
if (h >= 2) return "tall";
return "sm";
}
/* ── Indeling ────────────────────────────────────────────────────────────── */
interface Placement { id: string; col: number; row: number; w: number; h: number }
interface Box { col: number; row: number; w: number; h: number }
const overlaps = (a: Box, b: Box) => a.col < b.col + b.w && b.col < a.col + a.w && a.row < b.row + b.h && b.row < a.row + a.h;
const contains = (outer: Box, inner: Box) => inner.col >= outer.col && inner.row >= outer.row && inner.col + inner.w <= outer.col + outer.w && inner.row + inner.h <= outer.row + outer.h;
function spanOf(item: WidgetItem, columns: number) {
return { w: Math.min(SPANS[item.size].col, columns), h: SPANS[item.size].row };
}
/** Plaatst elke widget: eerst een exacte tegeling, anders een rijpakker die widgets laat groeien. */
function layout(items: WidgetItem[], columns: number): Placement[] {
if (columns < 1 || items.length === 0) return [];
return tile(items, columns) ?? pack(items, columns);
}
const TILING_BUDGET = 20000;
function tile(items: WidgetItem[], columns: number): Placement[] | null {
const spans = items.map((item) => spanOf(item, columns));
const area = spans.reduce((n, s) => n + s.w * s.h, 0);
const rows = Math.ceil(area / columns);
const grid: boolean[] = new Array(rows * columns).fill(false);
const used: boolean[] = new Array(items.length).fill(false);
const out: Placement[] = [];
let budget = TILING_BUDGET;
const fits = (w: number, h: number, r: number, c: number) => {
if (c + w > columns || r + h > rows) return false;
for (let y = r; y < r + h; y++) for (let x = c; x < c + w; x++) if (grid[y * columns + x]) return false;
return true;
};
const mark = (w: number, h: number, r: number, c: number, v: boolean) => {
for (let y = r; y < r + h; y++) for (let x = c; x < c + w; x++) grid[y * columns + x] = v;
};
const place = (count: number): boolean => {
if (count === items.length) return true;
if (--budget < 0) return false;
const i = grid.indexOf(false);
if (i < 0) return false;
const r = Math.floor(i / columns);
const c = i % columns;
const tried = new Set<string>();
for (let k = 0; k < items.length; k++) {
if (used[k]) continue;
const { w, h } = spans[k]!;
const shape = `${w}x${h}`;
if (tried.has(shape) || !fits(w, h, r, c)) continue;
tried.add(shape);
used[k] = true;
mark(w, h, r, c, true);
out.push({ id: items[k]!.id, col: c, row: r, w, h });
if (place(count + 1)) return true;
out.pop();
mark(w, h, r, c, false);
used[k] = false;
}
return false;
};
return place(0) ? out : null;
}
function pack(items: WidgetItem[], columns: number): Placement[] {
const out: Placement[] = [];
let row = 0;
let queue = items.map((item) => ({ id: item.id, ...spanOf(item, columns) }));
while (queue.length > 0) {
const height = Math.max(...queue.slice(0, columns).map((q) => q.h));
const cells: boolean[] = new Array(height * columns).fill(false);
const band: Placement[] = [];
const rest: typeof queue = [];
for (const q of queue) {
let spot = -1;
for (let i = 0; i < cells.length && spot < 0; i++) {
const r = Math.floor(i / columns);
const c = i % columns;
if (c + q.w > columns || r + q.h > height) continue;
let free = true;
for (let y = r; y < r + q.h && free; y++) for (let x = c; x < c + q.w && free; x++) if (cells[y * columns + x]) free = false;
if (free) spot = i;
}
if (spot < 0 || rest.length > 0) { rest.push(q); continue; }
const r = Math.floor(spot / columns);
const c = spot % columns;
for (let y = r; y < r + q.h; y++) for (let x = c; x < c + q.w; x++) cells[y * columns + x] = true;
band.push({ id: q.id, col: c, row: r, w: q.w, h: q.h });
}
for (let i = 0; i < cells.length; i++) {
if (cells[i]) continue;
const r = Math.floor(i / columns);
const c = i % columns;
const left = band.find((p) => p.col + p.w === c && p.row <= r && p.row + p.h > r && p.h === 1);
const above = band.find((p) => p.row + p.h === r && p.col === c && p.w === 1);
const grow = left ?? above;
if (!grow) continue;
if (grow === left) grow.w += 1; else grow.h += 1;
cells[i] = true;
}
out.push(...band.map((p) => ({ ...p, row: p.row + row })));
row += height;
queue = rest;
}
return out;
}
function canonical(items: WidgetItem[], columns: number): WidgetItem[] {
const places = layout(items, columns);
if (places.length !== items.length) return items;
const byId = new Map(items.map((item) => [item.id, item]));
const sorted = [...places].sort((a, b) => a.row - b.row || a.col - b.col).map((p) => byId.get(p.id) as WidgetItem);
if (sorted.every((item, i) => item === items[i])) return items;
const at = new Map(places.map((p) => [p.id, p]));
const same = layout(sorted, columns).every((p) => { const q = at.get(p.id); return q && q.col === p.col && q.row === p.row && q.w === p.w && q.h === p.h; });
return same ? sorted : items;
}
function moveTo(items: WidgetItem[], id: string, index: number) {
const from = items.findIndex((item) => item.id === id);
if (from < 0 || from === index || index < 0 || index >= items.length) return items;
const next = [...items];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved!);
return next;
}
const sameOrder = (a: WidgetItem[], b: WidgetItem[]) => a.length === b.length && a.every((item, i) => item.id === b[i]!.id);
/* ── Doelkeuze tijdens het slepen ───────────────────────────────────────── */
interface Slot { left: number; top: number; right: number; bottom: number }
interface Candidate { order: WidgetItem[]; slot: Slot }
const ENTER = 0.18;
function choose(home: Slot, candidates: Candidate[], cx: number, cy: number): WidgetItem[] | null {
const distance = (s: Slot, inset: number) => {
const ix = (s.right - s.left) * inset;
const iy = (s.bottom - s.top) * inset;
const dx = Math.max(s.left + ix - cx, 0, cx - (s.right - ix));
const dy = Math.max(s.top + iy - cy, 0, cy - (s.bottom - iy));
return Math.hypot(dx, dy);
};
const toCentre = (s: Slot) => Math.hypot((s.left + s.right) / 2 - cx, (s.top + s.bottom) / 2 - cy);
let best = distance(home, 0);
if (best === 0) return null;
let pick: WidgetItem[] | null = null;
let bestCentre = Infinity;
for (const { order, slot } of candidates) {
const d = distance(slot, ENTER);
const c = toCentre(slot);
if (d < best || (d === best && pick && c < bestCentre)) { best = d; bestCentre = c; pick = order; }
}
return pick;
}
function candidatesFor(items: WidgetItem[], id: string, columns: number, toSlot: (box: Box) => Slot): Candidate[] {
const places = layout(items, columns);
const me = places.find((p) => p.id === id);
if (!me) return [];
const byId = new Map(items.map((item) => [item.id, item]));
const rows = Math.max(...places.map((p) => p.row + p.h));
const out: Candidate[] = [];
for (let row = 0; row + me.h <= rows; row++) {
for (let col = 0; col + me.w <= columns; col++) {
const area = { col, row, w: me.w, h: me.h };
if (overlaps(area, me)) continue;
const group = places.filter((p) => overlaps(p, area));
if (group.length < 2 || !group.every((p) => contains(area, p))) continue;
const moved = places.map((p) => (p.id === id ? { ...p, col, row } : group.includes(p) ? { ...p, col: p.col - col + me.col, row: p.row - row + me.row } : p));
moved.sort((a, b) => a.row - b.row || a.col - b.col);
out.push({ order: moved.map((p) => byId.get(p.id) as WidgetItem), slot: toSlot(area) });
}
}
const from = items.findIndex((item) => item.id === id);
for (let i = 0; i < items.length; i++) {
if (i === from) continue;
const order = moveTo(items, id, i);
const p = layout(order, columns).find((q) => q.id === id);
if (p) out.push({ order, slot: toSlot(p) });
}
return out;
}
/* ── Widget ──────────────────────────────────────────────────────────────── */
const LIFT_SCALE = 1.06;
const SETTLE_MS = 40;
const LANDED_MS = 620;
const LONG_PRESS_MS = 350;
const PRESS_SLOP = 8;
interface Press { timer: number; pointerId: number; x: number; y: number }
type Phase = "idle" | "holding" | "lifted";
interface WidgetHandlers {
start: (id: string) => void;
drag: () => void;
end: (id: string) => void;
key: (e: React.KeyboardEvent, id: string) => void;
swallow: () => boolean;
suppressClick: () => void;
}
const Widget = memo(function Widget({ item, col, row, w, h, columns, rows, editable, held, raised, landed, handlers, hintId, position, count, renderItem }: {
item: WidgetItem; col: number; row: number; w: number; h: number; columns: number; rows: number; editable: boolean; held: boolean; raised: boolean; landed: boolean; handlers: WidgetHandlers; hintId: string; position: number; count: number; renderItem?: (item: WidgetItem, size: WidgetSize) => React.ReactNode;
}) {
const controls = useDragControls();
const node = useRef<HTMLDivElement | null>(null);
const press = useRef<Press | null>(null);
const lifted = useRef(false);
const cleanup = useRef<(() => void) | null>(null);
const [phase, setPhase] = useState<Phase>("idle");
const veer = useSpringTransition("smooth");
const til = useSpringTransition("pop");
const entree = useSpringTransition("smooth", { mass: "content", delay: (col / Math.max(columns, 1) + row / Math.max(rows, 1)) * 0.26 });
const release = useCallback(() => {
if (press.current) window.clearTimeout(press.current.timer);
press.current = null;
lifted.current = false;
cleanup.current?.();
cleanup.current = null;
setPhase("idle");
}, []);
useEffect(() => {
const el = node.current;
if (!el) return;
const block = (e: TouchEvent) => { if (lifted.current) e.preventDefault(); };
el.addEventListener("touchmove", block, { passive: false });
return () => {
el.removeEventListener("touchmove", block);
if (press.current) window.clearTimeout(press.current.timer);
cleanup.current?.();
};
}, []);
const onPointerDown = (e: React.PointerEvent) => {
if (!editable || e.button !== 0 || !e.isPrimary) return;
if (e.pointerType !== "touch") { controls.start(e); return; }
if (press.current || lifted.current) return;
const origin = e.nativeEvent;
const pointerId = e.pointerId;
setPhase("holding");
press.current = {
pointerId, x: e.clientX, y: e.clientY,
timer: window.setTimeout(() => {
press.current = null;
lifted.current = true;
setPhase("lifted");
navigator.vibrate?.(10);
controls.start(origin);
const done = (ev: PointerEvent) => { if (ev.pointerId !== pointerId) return; handlers.suppressClick(); release(); };
window.addEventListener("pointerup", done);
window.addEventListener("pointercancel", done);
cleanup.current = () => { window.removeEventListener("pointerup", done); window.removeEventListener("pointercancel", done); };
}, LONG_PRESS_MS),
};
};
const onPointerMove = (e: React.PointerEvent) => {
const p = press.current;
if (p && e.pointerId === p.pointerId && Math.hypot(e.clientX - p.x, e.clientY - p.y) > PRESS_SLOP) release();
};
const onPointerEnd = (e: React.PointerEvent) => { if (press.current?.pointerId === e.pointerId) release(); };
return (
<motion.div
ref={node}
role="listitem"
data-widget-id={item.id}
data-opgetild={phase === "lifted" || held ? "" : undefined}
tabIndex={editable ? 0 : undefined}
aria-label={item.label ?? `${SIZE_LABELS[item.size]} widget`}
aria-describedby={editable ? hintId : undefined}
aria-posinset={position}
aria-setsize={count}
layout="position"
drag={editable}
dragListener={false}
dragControls={controls}
dragSnapToOrigin
dragMomentum={false}
onDragStart={() => handlers.start(item.id)}
onDrag={handlers.drag}
onDragEnd={() => handlers.end(item.id)}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerEnd}
onPointerCancel={onPointerEnd}
onContextMenu={(e) => { if (phase !== "idle") e.preventDefault(); }}
onKeyDown={(e) => handlers.key(e, item.id)}
onClickCapture={(e) => { if (handlers.swallow() || (editable && (e.target as HTMLElement).closest("a"))) { e.preventDefault(); e.stopPropagation(); } }}
animate={{ scale: phase === "holding" ? 0.97 : phase === "lifted" ? LIFT_SCALE : 1 }}
whileDrag={{ scale: LIFT_SCALE, transition: til }}
transition={veer}
className={["fp-widgets__widget", editable ? "fp-widgets__widget--sleepbaar" : ""].filter(Boolean).join(" ")}
style={{ gridColumn: `${col + 1} / span ${w}`, gridRow: `${row + 1} / span ${h}`, zIndex: held ? 20 : raised ? 10 : 0 }}
>
<motion.div
initial={{ opacity: 0, y: 18, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={entree}
className={["fp-widgets__vlak", landed ? "fp-widgets__vlak--geland" : ""].filter(Boolean).join(" ")}
>
{renderItem?.(item, sizeOf(w, h))}
</motion.div>
</motion.div>
);
});
/* ── Raster ──────────────────────────────────────────────────────────────── */
export function WidgetGrid({ items: initialItems, onChange, renderItem, editable = true, maxColumns = 4, cellSize = 215, gap = 12, hint = "Sleep om te herschikken. Op een aanraakscherm eerst ingedrukt houden. Met een toetsenbord: Alt + pijltjestoetsen.", className }: WidgetGridProps) {
const [items, setItems] = useState(() => initialItems);
const grid = useRef<HTMLDivElement | null>(null);
const hintId = useId();
const minColumns = Math.min(2, Math.max(1, maxColumns));
const [metrics, setMetrics] = useState({ unit: 0, columns: 0 });
useIsoLayoutEffect(() => {
const el = grid.current;
if (!el) return;
const measure = () => {
const width = el.getBoundingClientRect().width;
if (width < 1) return;
const columns = Math.max(minColumns, Math.min(maxColumns, Math.round(width / cellSize)));
const unit = (width - gap * (columns - 1)) / columns;
setMetrics((was) => (was.columns === columns && Math.abs(was.unit - unit) < 0.5 ? was : { unit, columns }));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [maxColumns, minColumns, cellSize, gap]);
const columns = metrics.columns || Math.max(minColumns, maxColumns);
const placements = useMemo(() => layout(items, columns), [items, columns]);
const rows = placements.reduce((n, p) => Math.max(n, p.row + p.h), 0);
const latest = useRef({ items, metrics, onChange });
latest.current.metrics = metrics;
latest.current.onChange = onChange;
useIsoLayoutEffect(() => { latest.current.items = items; }, [items]);
const commit = useCallback((next: WidgetItem[]) => { latest.current.items = next; setItems(next); }, []);
const toSlot = useCallback((box: Box): Slot => {
const el = grid.current;
const { unit } = latest.current.metrics;
const rect = el?.getBoundingClientRect();
const colStep = unit + gap;
const rowStep = Math.round(unit) + gap;
const left = (rect?.left ?? 0) + box.col * colStep;
const top = (rect?.top ?? 0) + box.row * rowStep;
return { left, top, right: left + box.w * colStep - gap, bottom: top + box.h * rowStep - gap };
}, [gap]);
const [held, setHeld] = useState<string | null>(null);
const [raised, setRaised] = useState<string | null>(null);
const [landed, setLanded] = useState<string | null>(null);
const dragging = useRef<string | null>(null);
const startOrder = useRef<WidgetItem[] | null>(null);
const frame = useRef(0);
const lastMove = useRef(0);
const swallowUntil = useRef(0);
const ring = useRef(0);
const refocus = useRef<string | null>(null);
const step = useCallback((force = false) => {
frame.current = 0;
const id = dragging.current;
const { items: current, metrics: m } = latest.current;
const el = id ? (grid.current?.querySelector(`[data-widget-id="${CSS.escape(id)}"]`) as HTMLElement | null) : null;
if (!id || !el || !m.columns) return;
const now = performance.now();
if (!force && now - lastMove.current < SETTLE_MS) { frame.current = requestAnimationFrame(() => step()); return; }
const me = layout(current, m.columns).find((p) => p.id === id);
if (!me) return;
const r = el.getBoundingClientRect();
const order = choose(toSlot(me), candidatesFor(current, id, m.columns, toSlot), r.left + r.width / 2, r.top + r.height / 2);
if (!order) return;
lastMove.current = now;
commit(canonical(order, m.columns));
}, [toSlot, commit]);
useEffect(() => () => { cancelAnimationFrame(frame.current); window.clearTimeout(ring.current); }, []);
useIsoLayoutEffect(() => {
const id = refocus.current;
if (!id) return;
refocus.current = null;
(grid.current?.querySelector(`[data-widget-id="${CSS.escape(id)}"]`) as HTMLElement | null)?.focus();
}, [items]);
const handlers: WidgetHandlers = useMemo(() => ({
start: (id) => {
dragging.current = id;
startOrder.current = latest.current.items;
lastMove.current = 0;
setHeld(id);
setRaised(id);
window.addEventListener("pointerup", () => { swallowUntil.current = performance.now() + 300; }, { once: true, capture: true });
},
drag: () => { if (!frame.current) frame.current = requestAnimationFrame(() => step()); },
end: (id) => {
cancelAnimationFrame(frame.current);
step(true);
frame.current = 0;
dragging.current = null;
setHeld(null);
setLanded(id);
window.clearTimeout(ring.current);
ring.current = window.setTimeout(() => { setLanded(null); setRaised(null); }, LANDED_MS);
const before = startOrder.current;
startOrder.current = null;
const after = latest.current.items;
if (before && !sameOrder(before, after)) latest.current.onChange?.(after);
},
key: (e, id) => {
if (!editable || !e.altKey) return;
if ((e.target as HTMLElement).closest("input, textarea, select")) return;
const delta = e.key === "ArrowRight" || e.key === "ArrowDown" ? 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? -1 : 0;
if (!delta) return;
e.preventDefault();
const { items: current, metrics: m } = latest.current;
const cols = m.columns || maxColumns;
const from = current.findIndex((item) => item.id === id);
for (let to = from + delta; to >= 0 && to < current.length; to += delta) {
const next = canonical(moveTo(current, id, to), cols);
if (sameOrder(next, current)) continue;
refocus.current = id;
commit(next);
latest.current.onChange?.(next);
return;
}
},
swallow: () => performance.now() < swallowUntil.current,
suppressClick: () => { swallowUntil.current = performance.now() + 300; },
}), [step, commit, editable, maxColumns]);
const byId = useMemo(() => new Map(items.map((item) => [item.id, item])), [items]);
// DOM-volgorde blijft vast; alleen de rasterplaatsing verandert (anders herstart framer de mount-animatie).
const domOrder = useRef(items.map((item) => item.id));
for (const item of items) if (!domOrder.current.includes(item.id)) domOrder.current.push(item.id);
const placementById = new Map(placements.map((p) => [p.id, p]));
const visualIndex = new Map([...placements].sort((a, b) => a.row - b.row || a.col - b.col).map((p, i) => [p.id, i]));
return (
<MotionConfig reducedMotion="user">
<div className={["fp-widgets", className].filter(Boolean).join(" ")}>
{editable && <p id={hintId} className="fp-widgets__sr">{hint}</p>}
<div ref={grid} role="list" className="fp-widgets__raster" style={{ gap, gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gridAutoRows: metrics.unit ? `${Math.round(metrics.unit)}px` : `minmax(${cellSize * 0.75}px, auto)` }}>
{domOrder.current.map((id) => {
const item = byId.get(id);
const p = placementById.get(id);
if (!item || !p) return null;
return (
<Widget key={id} position={(visualIndex.get(id) ?? 0) + 1} count={placements.length} item={item} col={p.col} row={p.row} w={p.w} h={p.h} columns={columns} rows={rows} editable={editable} held={held === p.id} raised={raised === p.id} landed={landed === p.id} handlers={handlers} hintId={hintId} renderItem={renderItem} />
);
})}
</div>
</div>
</MotionConfig>
);
}
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; }