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.
Handtekening
Jamie de VriesTeken, 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) => ({ "<": "<", ">": ">", "&": "&", '"': """ })[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); }