← alle entries

widget-grid

FC-0167Data & lijstenwidget-gridblockv1.0.0

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.

zelfde component — andere tokens

Sleep om te herschikken. Op een aanraakscherm eerst ingedrukt houden. Met een toetsenbord: Alt + pijltjestoetsen.

Omzet€ 12.480+8% t.o.v. vorige maand
StatusAlles werkt3 diensten
Bezoekers4.210vandaag
Fouten2laatste 24 uur
Aanvragen7 nieuwSanne, Kim, Noor…
Score98Lighthouse
KanalenGoogle 62%Direct 21%, Social 17%
Uptime99,98%30 dagen

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; }