← alle entries

signature-pad

FC-0078Formulierensignatureblockv1.0.0

Een document ondertekenen op drie manieren — tekenen met drukgevoelige inkt, je naam typen in een schrijfletter, of een scan uploaden — plus hergebruik van bewaarde handtekeningen. Resultaat: bijgesneden PNG (2×) en, bij tekenen en typen, een SVG. Schrijfletters en inktkleuren komen van het project.

zelfde component — andere tokens

Handtekening

Jamie de Vries

Teken, typ of upload; het resultaat komt hier terecht. De schrijfletters zijn hier de generieke cursive/serif — een project laadt zijn eigen fonts.

blocks/signature/signature-pad.tsx
/**
 * SignaturePad — een document ondertekenen op drie manieren: tekenen (met
 * drukgevoelige inkt, of snelheid als druk bij een muis), je naam typen in een
 * schrijfletter, of een scan uploaden. Eerder gebruikte handtekeningen staan
 * onder een vierde tab klaar voor hergebruik. Het resultaat is een bijgesneden
 * PNG (2×) plus, bij tekenen en typen, een SVG.
 *
 * Receptuur: knoppen uit button (FC-0003), uploaden via file-dropzone
 * (FC-0042). De schrijfletters komen van het PROJECT (`typefaces`): deze
 * entry laadt geen fonts en noemt er geen — zonder typefaces is er geen
 * typ-tab. Inktkleuren komen ook van het project (`inks`); standaard is de
 * tekstkleur van de site.
 *
 * Bewaren en versturen doet het project: `onAccept` krijgt het resultaat,
 * `onSaveSignature`/`onRemoveSaved` de bibliotheek-wijzigingen.
 */

"use client";

import { useCallback, useEffect, useId, useRef, useState } from "react";
import type * as React from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Button } from "../../primitives/ui/button";
import { usePress } from "../../primitives/ui/press";
import { FileDropzone } from "../../primitives/ui/file-dropzone";
import { useSpringTransition } from "../../primitives/motion";

export type SignatureMethod = "draw" | "type" | "upload" | "saved";

export interface SignatureResult {
  method: SignatureMethod;
  /** Bijgesneden PNG met transparante achtergrond, 2× voor retina. */
  png: string;
  /** Vectorvorm — bij tekenen en typen. */
  svg?: string;
  /** De getypte naam, bij typen. */
  name?: string;
  /** Gebruikte inkt als CSS-kleur. */
  ink: string;
  at: Date;
}

export interface SavedSignature {
  id: string;
  png: string;
  svg?: string;
  /** Onder de handtekening, bv. "Volledige handtekening" of "Paraaf". */
  label: string;
  name?: string;
}

export interface Typeface {
  label: string;
  /** CSS font-family-waarde; het project laadt het font. */
  family: string;
}

export interface SignaturePadLabels {
  draw: string;
  type: string;
  upload: string;
  saved: string;
  drawHint: string;
  typePlaceholder: string;
  uploadHint: string;
  savedEmpty: string;
  clear: string;
  cancel: string;
  accept: string;
  saveToo: string;
  remove: string;
  emptyError: string;
}

export interface SignaturePadProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
  title?: React.ReactNode;
  /** Vult de typ-tab voor en komt mee in het resultaat. */
  signerName?: string;
  /** De regel onder het tekenvlak (instemming, datum). */
  consent?: React.ReactNode;
  defaultMethod?: SignatureMethod;
  /** Schrijfletters voor de typ-tab; zonder is er geen typ-tab. */
  typefaces?: Typeface[];
  /** Inktkleuren (CSS); standaard alleen de tekstkleur. */
  inks?: string[];
  saved?: SavedSignature[];
  onSaveSignature?: (signature: SavedSignature) => void;
  onRemoveSaved?: (id: string) => void;
  onAccept?: (result: SignatureResult) => void;
  onCancel?: () => void;
  labels?: Partial<SignaturePadLabels>;
}

const STANDAARD_LABELS: SignaturePadLabels = {
  draw: "Tekenen",
  type: "Typen",
  upload: "Uploaden",
  saved: "Bewaard",
  drawHint: "Teken je handtekening boven de lijn",
  typePlaceholder: "Typ je naam",
  uploadHint: "Een foto of scan van je handtekening (png, jpg)",
  savedEmpty: "Nog geen bewaarde handtekeningen.",
  clear: "Wissen",
  cancel: "Annuleren",
  accept: "Ondertekenen",
  saveToo: "Bewaar voor volgende keer",
  remove: "Verwijderen",
  emptyError: "Zet eerst een handtekening.",
};

/** Tapbaar element met de usePress-laag (touch-down, hit-marge) — nooit onClick. */
function Tap({ onPress, className, children, ...rest }: Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> & { onPress: () => void }) {
  const { pressed, pressProps } = usePress({ onPress, disabled: rest.disabled ?? false });
  return (
    <button {...rest} {...pressProps} type="button" data-pressed={pressed ? "" : undefined} className={className}>
      {children}
    </button>
  );
}

/* ── Inkt: punten → gladde, taps toelopende vorm → SVG/PNG ─────────────────── */

interface Pt {
  x: number;
  y: number;
  /** 0..1 — echte pen-druk, anders afgeleid van de snelheid. */
  p: number;
}

export interface Stroke {
  points: Pt[];
  color: string;
  size: number;
}

const num = (n: number) => Math.round(n * 100) / 100;

/** Voortschrijdend gemiddelde over drie punten: haalt de trappetjes uit een muis. */
function smooth(points: Pt[]): Pt[] {
  if (points.length < 3) return points;
  const eerste = points[0]!;
  const laatste = points[points.length - 1]!;
  const out: Pt[] = [eerste];
  for (let i = 1; i < points.length - 1; i += 1) {
    const a = points[i - 1]!;
    const b = points[i]!;
    const c = points[i + 1]!;
    out.push({ x: (a.x + b.x * 2 + c.x) / 4, y: (a.y + b.y * 2 + c.y) / 4, p: (a.p + b.p * 2 + c.p) / 4 });
  }
  out.push(laatste);
  return out;
}

const easeOut = (t: number) => 1 - (1 - t) * (1 - t);

/** Eén zijde van de omtrek, door de middens van de segmenten zodat de hoeken zacht zijn. */
function zijde(points: Array<[number, number]>, vervolg = false): string {
  const [eerste, ...rest] = points;
  if (!eerste) return "";
  let d = `${vervolg ? "L" : "M"} ${num(eerste[0])} ${num(eerste[1])}`;
  for (let i = 0; i < rest.length - 1; i += 1) {
    const [x, y] = rest[i]!;
    const [nx, ny] = rest[i + 1]!;
    d += ` Q ${num(x)} ${num(y)} ${num((x + nx) / 2)} ${num((y + ny) / 2)}`;
  }
  const laatste = rest[rest.length - 1];
  if (laatste) d += ` L ${num(laatste[0])} ${num(laatste[1])}`;
  return d;
}

/** De omtrek van één haal als SVG-pad — hetzelfde pad voor canvas (Path2D) en export. */
export function strokePath(stroke: Stroke): string {
  const pts = smooth(stroke.points);
  const basis = stroke.size / 2;
  if (pts.length === 1) {
    const { x, y, p } = pts[0]!;
    const r = num(basis * (0.4 + 0.6 * p));
    return `M ${num(x - r)} ${num(y)} a ${r} ${r} 0 1 0 ${num(r * 2)} 0 a ${r} ${r} 0 1 0 ${num(-r * 2)} 0 Z`;
  }
  const len: number[] = [0];
  for (let i = 1; i < pts.length; i += 1) len.push(len[i - 1]! + Math.hypot(pts[i]!.x - pts[i - 1]!.x, pts[i]!.y - pts[i - 1]!.y));
  const totaal = len[len.length - 1]!;
  const taps = Math.min(stroke.size * 3.5, totaal / 2);
  const links: Array<[number, number]> = [];
  const rechts: Array<[number, number]> = [];
  for (let i = 0; i < pts.length; i += 1) {
    const vorige = pts[Math.max(0, i - 1)]!;
    const volgende = pts[Math.min(pts.length - 1, i + 1)]!;
    let dx = volgende.x - vorige.x;
    let dy = volgende.y - vorige.y;
    const d = Math.hypot(dx, dy) || 1;
    dx /= d;
    dy /= d;
    const vanBegin = taps > 0 ? Math.min(1, len[i]! / taps) : 1;
    const vanEind = taps > 0 ? Math.min(1, (totaal - len[i]!) / taps) : 1;
    const r = basis * (0.32 + 0.68 * pts[i]!.p) * easeOut(Math.min(vanBegin, vanEind));
    links.push([pts[i]!.x - dy * r, pts[i]!.y + dx * r]);
    rechts.push([pts[i]!.x + dy * r, pts[i]!.y - dx * r]);
  }
  return `${zijde(links)} ${zijde(rechts.reverse(), true)} Z`;
}

function grenzen(strokes: Stroke[], marge = 10) {
  let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
  for (const s of strokes) {
    const r = s.size / 2;
    for (const pt of s.points) {
      x0 = Math.min(x0, pt.x - r); y0 = Math.min(y0, pt.y - r); x1 = Math.max(x1, pt.x + r); y1 = Math.max(y1, pt.y + r);
    }
  }
  if (!Number.isFinite(x0)) return null;
  return { x: x0 - marge, y: y0 - marge, w: x1 - x0 + marge * 2, h: y1 - y0 + marge * 2 };
}

export function strokesToSvg(strokes: Stroke[]): string | undefined {
  const b = grenzen(strokes);
  if (!b) return undefined;
  const paden = strokes
    .map((s) => `<path d="${strokePath({ ...s, points: s.points.map((p) => ({ ...p, x: p.x - b.x, y: p.y - b.y })) })}" fill="${s.color}"/>`)
    .join("");
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${num(b.w)}" height="${num(b.h)}" viewBox="0 0 ${num(b.w)} ${num(b.h)}">${paden}</svg>`;
}

export function strokesToPng(strokes: Stroke[], schaal = 2): string | undefined {
  const b = grenzen(strokes);
  if (!b) return undefined;
  const canvas = document.createElement("canvas");
  canvas.width = Math.ceil(b.w * schaal);
  canvas.height = Math.ceil(b.h * schaal);
  const ctx = canvas.getContext("2d");
  if (!ctx) return undefined;
  ctx.scale(schaal, schaal);
  ctx.translate(-b.x, -b.y);
  for (const s of strokes) {
    ctx.fillStyle = s.color;
    ctx.fill(new Path2D(strokePath(s)));
  }
  return canvas.toDataURL("image/png");
}

/* ── Tekenvlak ─────────────────────────────────────────────────────────────── */

const NIB = 6;

function DrawBoard({ strokes, setStrokes, color, hint }: { strokes: Stroke[]; setStrokes: React.Dispatch<React.SetStateAction<Stroke[]>>; color: string; hint: string }) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const live = useRef<Stroke | null>(null);
  const laatste = useRef<{ x: number; y: number; t: number; p: number } | null>(null);
  const [tekent, setTekent] = useState(false);
  const fade = useSpringTransition("default");

  const teken = useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;
    const dpr = window.devicePixelRatio || 1;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
    const alles = live.current ? [...strokes, live.current] : strokes;
    for (const s of alles) {
      ctx.fillStyle = s.color;
      ctx.fill(new Path2D(strokePath(s)));
    }
  }, [strokes]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const pas = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      canvas.width = Math.round(rect.width * dpr);
      canvas.height = Math.round(rect.height * dpr);
      teken();
    };
    pas();
    const ro = new ResizeObserver(pas);
    ro.observe(canvas);
    return () => ro.disconnect();
  }, [teken]);

  useEffect(teken, [teken]);

  const punt = (e: React.PointerEvent<HTMLCanvasElement>): Pt => {
    const rect = e.currentTarget.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    // Een pen meldt druk; een muis meldt altijd 0.5 — dan staat de snelheid
    // van de hand ervoor in: snel is licht, langzaam is zwaar.
    let p: number;
    const nu = performance.now();
    const l = laatste.current;
    if (e.pointerType === "pen" && e.pressure > 0 && e.pressure !== 0.5) p = e.pressure;
    else if (l) {
      const dt = Math.max(1, nu - l.t);
      const snelheid = Math.hypot(x - l.x, y - l.y) / dt;
      const doel = Math.max(0.28, Math.min(1, 1 - snelheid / 2.4));
      p = l.p + (doel - l.p) * 0.45;
    } else p = 0.65;
    laatste.current = { x, y, t: nu, p };
    return { x, y, p };
  };

  const onDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
    e.currentTarget.setPointerCapture(e.pointerId);
    laatste.current = null;
    live.current = { points: [punt(e)], color, size: NIB };
    setTekent(true);
    teken();
  };
  const onMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
    const l = live.current;
    if (!l) return;
    const pt = punt(e);
    const vorige = l.points[l.points.length - 1]!;
    if (Math.hypot(pt.x - vorige.x, pt.y - vorige.y) < 1.1) return;
    l.points.push(pt);
    teken();
  };
  const einde = () => {
    const l = live.current;
    live.current = null;
    laatste.current = null;
    setTekent(false);
    if (l && l.points.length) setStrokes((s) => [...s, l]);
  };

  const leeg = strokes.length === 0 && !tekent;
  return (
    <div className="fp-sig__vlak">
      <canvas ref={canvasRef} className="fp-sig__doek" role="img" aria-label={hint} onPointerDown={onDown} onPointerMove={onMove} onPointerUp={einde} onPointerCancel={einde} />
      <div className="fp-sig__lijn" aria-hidden="true">
        <AnimatePresence>
          {leeg && (
            <motion.p className="fp-sig__hint" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={fade}>
              {hint}
            </motion.p>
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

/* ── Typen ─────────────────────────────────────────────────────────────────── */

/** Grootste lettergrootte waarbij de naam in het vlak past (canvas-meting). */
function pasTekst(tekst: string, family: string, maxBreedte: number, maxHoogte: number, plafond = 56): number {
  const ctx = document.createElement("canvas").getContext("2d");
  if (!ctx || !tekst) return plafond;
  let maat = plafond;
  while (maat > 14) {
    ctx.font = `${maat}px ${family}`;
    if (ctx.measureText(tekst).width <= maxBreedte && maat * 1.3 <= maxHoogte) break;
    maat -= 2;
  }
  return maat;
}

function tekstNaarSvg(tekst: string, family: string, kleur: string, maat: number): { svg: string; png: string } | null {
  const ctx = document.createElement("canvas").getContext("2d");
  if (!ctx) return null;
  ctx.font = `${maat}px ${family}`;
  const m = ctx.measureText(tekst);
  const w = Math.ceil(m.width + maat * 0.4);
  const h = Math.ceil(maat * 1.5);
  const y = Math.round(maat * 1.05);
  const veilig = tekst.replace(/[<>&"]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c] ?? c);
  const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><text x="${Math.round(maat * 0.2)}" y="${y}" font-family="${family.replace(/"/g, "'")}" font-size="${maat}" fill="${kleur}">${veilig}</text></svg>`;
  const canvas = document.createElement("canvas");
  canvas.width = w * 2;
  canvas.height = h * 2;
  const c2 = canvas.getContext("2d");
  if (!c2) return null;
  c2.scale(2, 2);
  c2.font = `${maat}px ${family}`;
  c2.fillStyle = kleur;
  c2.fillText(tekst, Math.round(maat * 0.2), y);
  return { svg, png: canvas.toDataURL("image/png") };
}

/* ── Het paneel ────────────────────────────────────────────────────────────── */

export function SignaturePad({
  title = "Handtekening",
  signerName = "",
  consent,
  defaultMethod = "draw",
  typefaces = [],
  inks,
  saved = [],
  onSaveSignature,
  onRemoveSaved,
  onAccept,
  onCancel,
  labels: eigenLabels,
  className,
  ...rest
}: SignaturePadProps) {
  const L = { ...STANDAARD_LABELS, ...eigenLabels };
  const id = useId();
  const wortel = useRef<HTMLDivElement>(null);
  const [standaardInkt, setStandaardInkt] = useState("currentColor");
  const inkten = inks && inks.length > 0 ? inks : [standaardInkt];
  const [inkt, setInkt] = useState(inkten[0]!);
  const [methode, setMethode] = useState<SignatureMethod>(typefaces.length === 0 && defaultMethod === "type" ? "draw" : defaultMethod);
  const [strokes, setStrokes] = useState<Stroke[]>([]);
  const [naam, setNaam] = useState(signerName);
  const [face, setFace] = useState<Typeface | null>(typefaces[0] ?? null);
  const [beeld, setBeeld] = useState<string | null>(null);
  const [gekozenBewaard, setGekozenBewaard] = useState<string | null>(saved[0]?.id ?? null);
  const [bewaarOok, setBewaarOok] = useState(false);
  const [fout, setFout] = useState<string | null>(null);
  const schuif = useSpringTransition("smooth");

  // De standaardinkt is de tekstkleur van het paneel — opgelezen, nooit benoemd.
  useEffect(() => {
    const el = wortel.current;
    if (!el) return;
    const kleur = getComputedStyle(el).color;
    if (kleur) {
      setStandaardInkt(kleur);
      if (!inks || inks.length === 0) setInkt(kleur);
    }
  }, [inks]);

  const tabs: SignatureMethod[] = ["draw", ...(typefaces.length > 0 ? (["type"] as const) : []), "upload", ...(saved.length > 0 ? (["saved"] as const) : [])];

  const wis = () => {
    setStrokes([]);
    setBeeld(null);
    setFout(null);
  };

  const onderteken = () => {
    let resultaat: SignatureResult | null = null;
    if (methode === "draw") {
      const png = strokesToPng(strokes);
      if (png) resultaat = { method: "draw", png, svg: strokesToSvg(strokes), ink: inkt, at: new Date() };
    } else if (methode === "type" && face) {
      const tekst = naam.trim();
      if (tekst) {
        const maat = pasTekst(tekst, face.family, 520, 120);
        const uit = tekstNaarSvg(tekst, face.family, inkt, maat);
        if (uit) resultaat = { method: "type", png: uit.png, svg: uit.svg, name: tekst, ink: inkt, at: new Date() };
      }
    } else if (methode === "upload") {
      if (beeld) resultaat = { method: "upload", png: beeld, ink: inkt, at: new Date() };
    } else if (methode === "saved") {
      const s = saved.find((x) => x.id === gekozenBewaard);
      if (s) resultaat = { method: "saved", png: s.png, svg: s.svg, name: s.name, ink: inkt, at: new Date() };
    }
    if (!resultaat) {
      setFout(L.emptyError);
      return;
    }
    setFout(null);
    if (bewaarOok && methode !== "saved") {
      onSaveSignature?.({ id: `${Date.now().toString(36)}`, png: resultaat.png, svg: resultaat.svg, label: methode === "type" ? naam.trim() : L[methode], name: resultaat.name ?? (naam.trim() || undefined) });
    }
    onAccept?.(resultaat);
  };

  const leesBestand = (files: File[]) => {
    const f = files[0];
    if (!f) return;
    const lezer = new FileReader();
    lezer.onload = () => setBeeld(typeof lezer.result === "string" ? lezer.result : null);
    lezer.readAsDataURL(f);
  };

  // De lettergrootte voor de typ-tab wordt op de client gemeten (canvas) — nooit tijdens de server-render.
  const [typMaat, setTypMaat] = useState(40);
  useEffect(() => {
    if (face) setTypMaat(pasTekst(naam.trim() || L.typePlaceholder, face.family, 420, 110));
  }, [face, naam, L.typePlaceholder]);

  return (
    <div {...rest} ref={wortel} className={["fp-sig", className].filter(Boolean).join(" ")}>
      <div className="fp-sig__kop">
        <h3 className="fp-sig__titel">{title}</h3>
        {signerName && <span className="fp-sig__naam">{signerName}</span>}
      </div>

      <div className="fp-sig__tabs" role="tablist" aria-label={typeof title === "string" ? title : undefined}>
        {tabs.map((t) => (
          <Tap
            key={t}
            role="tab"
            id={`${id}-tab-${t}`}
            aria-selected={methode === t}
            aria-controls={`${id}-paneel`}
            className="fp-sig__tab"
            onPress={() => {
              setMethode(t);
              setFout(null);
            }}
          >
            {methode === t && <motion.span layoutId={`${id}-tabvlak`} className="fp-sig__tabvlak" transition={schuif} aria-hidden="true" />}
            <span className="fp-sig__tabtekst">{L[t]}</span>
          </Tap>
        ))}
      </div>

      <div id={`${id}-paneel`} role="tabpanel" aria-labelledby={`${id}-tab-${methode}`} className="fp-sig__paneel">
        {methode === "draw" && <DrawBoard strokes={strokes} setStrokes={setStrokes} color={inkt} hint={L.drawHint} />}
        {methode === "type" && face && (
          <div className="fp-sig__typ" style={{ ["--fp-sig-face" as string]: face.family, ["--fp-sig-maat" as string]: `${typMaat}px`, color: inkt } as React.CSSProperties}>
            <input className="fp-sig__typveld" value={naam} placeholder={L.typePlaceholder} onChange={(e) => setNaam(e.target.value)} aria-label={L.type} />
            {typefaces.length > 1 && (
              <div className="fp-sig__faces" role="radiogroup" aria-label={L.type}>
                {typefaces.map((f) => (
                  <Tap key={f.family} role="radio" aria-checked={face.family === f.family} className="fp-sig__face" style={{ ["--fp-sig-face" as string]: f.family } as React.CSSProperties} onPress={() => setFace(f)}>
                    {f.label}
                  </Tap>
                ))}
              </div>
            )}
          </div>
        )}
        {methode === "upload" && (
          <div className="fp-sig__upload">
            {beeld ? (
              /* eslint-disable-next-line @next/next/no-img-element -- data-URL van de gebruiker */
              <img className="fp-sig__scan" src={beeld} alt="" />
            ) : (
              <FileDropzone accept="image/*" multiple={false} maxSize={4 * 1024 * 1024} onFilesChange={leesBestand} labels={{ idle: L.uploadHint }} />
            )}
          </div>
        )}
        {methode === "saved" && (
          <div className="fp-sig__bewaard" role="radiogroup" aria-label={L.saved}>
            {saved.length === 0 && <p className="fp-sig__leeg">{L.savedEmpty}</p>}
            {saved.map((s) => (
              <div key={s.id} className="fp-sig__bewaard-item" data-gekozen={gekozenBewaard === s.id ? "" : undefined}>
                <Tap role="radio" aria-checked={gekozenBewaard === s.id} className="fp-sig__bewaard-knop" onPress={() => setGekozenBewaard(s.id)}>
                  {/* eslint-disable-next-line @next/next/no-img-element -- bewaarde PNG */}
                  <img src={s.png} alt={s.label} />
                  <span>{s.label}</span>
                </Tap>
                {onRemoveSaved && (
                  <Tap className="fp-sig__bewaard-weg" onPress={() => onRemoveSaved(s.id)} aria-label={`${L.remove}: ${s.label}`}>
                    ×
                  </Tap>
                )}
              </div>
            ))}
          </div>
        )}
        {consent && <p className="fp-sig__consent">{consent}</p>}
      </div>

      <div className="fp-sig__voet">
        <div className="fp-sig__inkten" role="radiogroup" aria-label="Inkt">
          {inkten.length > 1 &&
            inkten.map((k) => (
              <Tap key={k} role="radio" aria-checked={inkt === k} aria-label={k} className="fp-sig__inkt" style={{ background: k }} onPress={() => setInkt(k)} />
            ))}
          {methode !== "saved" && onSaveSignature && (
            <label className="fp-sig__bewaar">
              <input type="checkbox" checked={bewaarOok} onChange={(e) => setBewaarOok(e.target.checked)} /> {L.saveToo}
            </label>
          )}
        </div>
        <div className="fp-sig__acties">
          {fout && <span className="fp-sig__fout" role="alert">{fout}</span>}
          {(methode === "draw" || methode === "upload") && (
            <Button variant="ghost" onPress={wis}>{L.clear}</Button>
          )}
          {onCancel && <Button variant="ghost" onPress={onCancel}>{L.cancel}</Button>}
          <Button onPress={onderteken}>{L.accept}</Button>
        </div>
      </div>
    </div>
  );
}
blocks/signature/signature.css
/**
 * Signature-blocks: signature-pad.
 * Importeren in de app-root (de registry-site doet dat in app/globals.css).
 */

.fp-sig {
  display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 36rem; padding: 1rem; box-sizing: border-box;
  background: var(--card); color: var(--card-foreground, var(--foreground));
  border: 1px solid var(--border); border-radius: calc(var(--radius) * 2);
}
.fp-sig__kop { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.fp-sig__titel { margin: 0; font: inherit; font-weight: 600; font-size: 1rem; }
.fp-sig__naam { font-size: 0.8125rem; color: var(--muted-foreground); }
/* Tabs: één glijdend vlak (layoutId) achter de gekozen tab. */
.fp-sig__tabs { position: relative; display: flex; gap: 0.25rem; padding: 0.25rem; background: var(--muted); border-radius: var(--radius); }
.fp-sig__tab {
  position: relative; flex: 1; padding: 0.4rem 0.5rem; border: 0; background: none; color: var(--muted-foreground); font: inherit; font-size: 0.8125rem; font-weight: 500;
  border-radius: calc(var(--radius) * 0.75); cursor: pointer; outline: none;
  transition: color var(--motion-instant-duration) var(--motion-instant);
}
.fp-sig__tab[aria-selected="true"] { color: var(--foreground); }
.fp-sig__tab:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-sig__tabvlak { position: absolute; inset: 0; background: var(--card); border-radius: calc(var(--radius) * 0.75); box-shadow: 0 1px 2px var(--shadow-overlay); }
.fp-sig__tabtekst { position: relative; }
.fp-sig__paneel { display: flex; flex-direction: column; gap: 0.5rem; }
/* Tekenvlak */
.fp-sig__vlak { position: relative; border: 1px dashed var(--border); border-radius: var(--radius); background: var(--background); }
.fp-sig__doek { display: block; width: 100%; height: 196px; touch-action: none; cursor: crosshair; }
.fp-sig__lijn { position: absolute; left: 2rem; right: 2rem; bottom: 52px; pointer-events: none; }
.fp-sig__lijn::before { content: ""; display: block; height: 1px; width: 100%; background: color-mix(in srgb, var(--foreground) 14%, transparent); }
.fp-sig__hint { margin: 0.5rem 0 0; text-align: center; font-size: 0.78rem; color: var(--muted-foreground); }
/* Typen: de schrijfletter komt van het project via --fp-sig-face. */
.fp-sig__typ { display: flex; flex-direction: column; gap: 0.75rem; }
.fp-sig__typveld {
  width: 100%; height: 196px; box-sizing: border-box; padding: 0 2rem; border: 1px dashed var(--border); border-radius: var(--radius); background: var(--background);
  font-family: var(--fp-sig-face); font-size: var(--fp-sig-maat, 40px); line-height: 1; color: inherit; text-align: center; outline: none;
}
.fp-sig__typveld::placeholder { color: var(--muted-foreground); opacity: 0.6; }
.fp-sig__typveld:focus-visible { border-color: var(--ring); }
.fp-sig__faces { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.fp-sig__face {
  padding: 0.35rem 0.75rem; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--foreground); cursor: pointer;
  font-family: var(--fp-sig-face); font-size: 1rem; line-height: 1; outline: none;
  transition: border-color var(--motion-instant-duration) var(--motion-instant), background-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-sig__face[aria-checked="true"] { border-color: var(--foreground); background: var(--muted); }
.fp-sig__face:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
/* Upload */
.fp-sig__upload { min-height: 196px; display: flex; align-items: center; justify-content: center; }
.fp-sig__scan { max-width: 100%; max-height: 196px; object-fit: contain; border-radius: var(--radius); }
/* Bewaard */
.fp-sig__bewaard { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.5rem; }
.fp-sig__bewaard-item { position: relative; }
.fp-sig__bewaard-knop {
  display: flex; flex-direction: column; gap: 0.375rem; width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--background); cursor: pointer; outline: none; color: var(--foreground); font: inherit; font-size: 0.75rem;
  transition: border-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-sig__bewaard-knop img { width: 100%; height: 4rem; object-fit: contain; }
.fp-sig__bewaard-item[data-gekozen] .fp-sig__bewaard-knop { border-color: var(--foreground); }
.fp-sig__bewaard-knop:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-sig__bewaard-weg { position: absolute; top: 0.25rem; right: 0.25rem; width: 1.5rem; height: 1.5rem; border: 0; border-radius: 50%; background: var(--muted); color: var(--muted-foreground); cursor: pointer; font: inherit; line-height: 1; }
.fp-sig__leeg { margin: 0; padding: 2rem 0; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }
.fp-sig__consent { margin: 0; font-size: 0.75rem; color: var(--muted-foreground); }
/* Voet: inkt, bewaren, acties */
.fp-sig__voet { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 0.75rem; }
.fp-sig__inkten { display: flex; align-items: center; gap: 0.5rem; }
.fp-sig__inkt { width: 1.25rem; height: 1.25rem; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; outline: none; box-shadow: 0 0 0 1px var(--border); }
.fp-sig__inkt[aria-checked="true"] { box-shadow: 0 0 0 2px var(--foreground); }
.fp-sig__inkt:focus-visible { box-shadow: 0 0 0 2px var(--ring); }
.fp-sig__bewaar { display: inline-flex; align-items: center; gap: 0.375rem; font-size: 0.75rem; color: var(--muted-foreground); }
.fp-sig__acties { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
.fp-sig__fout { font-size: 0.75rem; color: var(--danger); }