Unterschriftsfeld
Eine Unterschrift mit Finger, Stift oder Maus, Tinte, die mit dem Tempo atmet, Zurücknehmen und Leeren, Tippen als Alternative für die Tastatur, und SVG als Wert.
Eine von 6 Components in Spezielle Eingabefelder
Variants
Details
Was Unterschriftsfeld mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Spezielle Eingabefelder
- Bereich
- Elements
- Variants
- 4
- Version
- 1.0.0 · seit 1.1.0
- Datei
- components/ui/signature-pad.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 18 eigene Texte
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SignaturePadProps aus components/ui/signature-pad.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalSignaturePadVariantdrawcontractinitialsdialog
labeloptionalstringA label above the pad
signeroptionalstringWho signs, shown under the line and used for the typed signature
termsoptionalstring[]contract: the text to agree to, a short paragraph per entry
agreeLabeloptionalstringcontract: the checkbox text
sectionsoptionalstring[]initials: one small pad per entry, e.g. section names
allowTypedoptionalbooleanOffer typing the name instead of drawing. Default true.
defaultValueoptionalStroke[]draw, contract: strokes already on the pad, in a 600 × 200 space
onChangeoptional(value: SignatureValue) => voidonSignoptional(value: SignatureValue) => voidcontract, dialog: called with the final signature
Eingebaute Texte
Diese Texte schreibt Unterschriftsfeld selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 18 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| area | Englisch Signature area | Deutsch Unterschriftsfeld |
| help | Englisch Sign with your finger, a pen or the mouse. | Deutsch Mit Finger, Stift oder Maus unterschreiben. |
| draw | Englisch Draw | Deutsch Zeichnen |
| type | Englisch Type | Deutsch Tippen |
| typeLabel | Englisch Your full name | Deutsch Dein vollständiger Name |
| clear | Englisch Clear | Deutsch Leeren |
| undo | Englisch Undo last stroke | Deutsch Letzten Strich zurücknehmen |
| empty | Englisch Sign here | Deutsch Hier unterschreiben |
| signed | Englisch Signed | Deutsch Unterschrieben |
| sign | Englisch Sign | Deutsch Unterschreiben |
| change | Englisch Sign again | Deutsch Neu unterschreiben |
| done | Englisch Use this signature | Deutsch Diese Unterschrift nutzen |
| cancel | Englisch Cancel | Deutsch Abbrechen |
| signedOn | Englisch Signed on | Deutsch Unterschrieben am |
| by | Englisch by | Deutsch von |
| initials | Englisch Initials | Deutsch Kürzel |
| required | Englisch Please sign before you continue. | Deutsch Bitte unterschreib, bevor du weitermachst. |
| mode | Englisch How do you want to sign? | Deutsch Wie möchtest du unterschreiben? |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Unterschriftsfeld reagiert selbst auf die Tasten Escape, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Tab und Leertaste.
- Setzt aria-checked, aria-describedby, aria-haspopup, aria-label, aria-labelledby, aria-live und aria-modal, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rollen alert, dialog, img, radio und radiogroup.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Jedes Feld hat ein Label, sichtbar oder für Screenreader.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 566 Zeilen
'use client';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { cn, round, useLocale, useTexts } from '@/lib/kit';
import { Icon } from '@/components/ui/icon';
import { useMounted } from '@/components/ui/popover';
/*
* A signature drawn with finger, pen or mouse. Strokes are kept as points
* and drawn as smooth SVG paths, thicker when slow and thinner when fast
* (or by pen pressure), in the text color, so the ink is right in light and
* dark. Drawing has no keyboard equivalent, so every pad offers to type the
* name instead. The value comes back as an SVG string you can store or print.
*/
const TEXTS = {
en: {
area: 'Signature area', help: 'Sign with your finger, a pen or the mouse.', draw: 'Draw', type: 'Type', typeLabel: 'Your full name', clear: 'Clear', undo: 'Undo last stroke',
empty: 'Sign here', signed: 'Signed', sign: 'Sign', change: 'Sign again', done: 'Use this signature', cancel: 'Cancel', signedOn: 'Signed on', by: 'by', initials: 'Initials',
required: 'Please sign before you continue.', mode: 'How do you want to sign?',
},
de: {
area: 'Unterschriftsfeld', help: 'Mit Finger, Stift oder Maus unterschreiben.', draw: 'Zeichnen', type: 'Tippen', typeLabel: 'Dein vollständiger Name', clear: 'Leeren', undo: 'Letzten Strich zurücknehmen',
empty: 'Hier unterschreiben', signed: 'Unterschrieben', sign: 'Unterschreiben', change: 'Neu unterschreiben', done: 'Diese Unterschrift nutzen', cancel: 'Abbrechen', signedOn: 'Unterschrieben am', by: 'von', initials: 'Kürzel',
required: 'Bitte unterschreib, bevor du weitermachst.', mode: 'Wie möchtest du unterschreiben?',
},
};
type Texts = (typeof TEXTS)['en'];
type Pt = [number, number, number]; // x, y, width
export type Stroke = Pt[];
/** One stroke as a smooth outline: points joined by quadratic curves through their midpoints */
function strokePath(s: Stroke) {
if (s.length === 1) {
const [x, y, w] = s[0];
return { d: `M${round(x, 1)} ${round(y, 1)}h0.01`, w };
}
let d = `M${round(s[0][0], 1)} ${round(s[0][1], 1)}`;
for (let i = 1; i < s.length - 1; i++) {
const mx = (s[i][0] + s[i + 1][0]) / 2;
const my = (s[i][1] + s[i + 1][1]) / 2;
d += ` Q${round(s[i][0], 1)} ${round(s[i][1], 1)} ${round(mx, 1)} ${round(my, 1)}`;
}
const last = s[s.length - 1];
d += ` L${round(last[0], 1)} ${round(last[1], 1)}`;
const w = s.reduce((a, p) => a + p[2], 0) / s.length;
return { d, w };
}
/** The strokes as a standalone SVG, for storing or printing */
export function signatureToSvg(strokes: Stroke[], width: number, height: number, color = '#111111') {
const paths = strokes.map((s) => strokePath(s)).map((p) => `<path d="${p.d}" stroke-width="${round(p.w, 2)}"/>`).join('');
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" fill="none" stroke="${color}" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
}
export interface SignatureValue {
/** Drawn strokes, empty when typed */
strokes: Stroke[];
/** The typed name, when typed */
typed?: string;
svg: string;
empty: boolean;
}
/** The drawing surface, in a fixed coordinate space so strokes scale with the box */
function Pad({ strokes, onStrokes, t, vw, vh, className, baseline = true, placeholder, disabled }: { strokes: Stroke[]; onStrokes: (s: Stroke[]) => void; t: Texts; vw: number; vh: number; className?: string; baseline?: boolean; placeholder?: string; disabled?: boolean }) {
const ref = useRef<SVGSVGElement>(null);
const current = useRef<{ stroke: Stroke; last: { x: number; y: number; t: number } } | null>(null);
const [live, setLive] = useState<Stroke | null>(null);
const helpId = useId();
const point = (e: React.PointerEvent): Pt & { t?: number } => {
const r = ref.current!.getBoundingClientRect();
const x = ((e.clientX - r.left) / r.width) * vw;
const y = ((e.clientY - r.top) / r.height) * vh;
return [x, y, 0];
};
const widthFor = (e: React.PointerEvent, speed: number) => {
// A pen reports pressure, a finger or mouse gets the width from its speed
if (e.pointerType === 'pen' && e.pressure > 0) return 1.2 + e.pressure * 3.2;
return Math.max(1.3, Math.min(3.6, 3.8 - speed * 1.6));
};
return (
<div className="relative">
<svg
ref={ref}
viewBox={`0 0 ${vw} ${vh}`}
role="img"
aria-label={strokes.length ? `${t.area}, ${t.signed}` : t.area}
aria-describedby={helpId}
onPointerDown={(e) => {
if (disabled || e.button !== 0) return;
e.preventDefault();
(e.currentTarget as Element).setPointerCapture(e.pointerId);
const [x, y] = point(e);
current.current = { stroke: [[x, y, widthFor(e, 0)]], last: { x, y, t: performance.now() } };
setLive(current.current.stroke);
}}
onPointerMove={(e) => {
const c = current.current;
if (!c) return;
const [x, y] = point(e);
const now = performance.now();
const dist = Math.hypot(x - c.last.x, y - c.last.y);
if (dist < 1.2) return;
const speed = dist / Math.max(1, now - c.last.t);
// Smooth the width so the line breathes instead of jumping
const prev = c.stroke[c.stroke.length - 1][2];
const w = prev * 0.7 + widthFor(e, speed) * 0.3;
c.stroke = [...c.stroke, [x, y, w]];
c.last = { x, y, t: now };
setLive(c.stroke);
}}
onPointerUp={() => {
const c = current.current;
current.current = null;
setLive(null);
if (c) onStrokes([...strokes, c.stroke]);
}}
onPointerCancel={() => {
current.current = null;
setLive(null);
}}
className={cn('block w-full touch-none select-none text-foreground', disabled ? 'cursor-default' : 'cursor-crosshair', className)}
>
{baseline && (
<g aria-hidden>
<line x1={vw * 0.06} x2={vw * 0.94} y1={vh * 0.74} y2={vh * 0.74} className="stroke-border-strong" strokeWidth="1" strokeDasharray="4 4" />
</g>
)}
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
{strokes.map((s, i) => {
const p = strokePath(s);
return <path key={i} d={p.d} strokeWidth={round(p.w, 2)} />;
})}
{live && (() => {
const p = strokePath(live);
return <path d={p.d} strokeWidth={round(p.w, 2)} />;
})()}
</g>
<desc id={helpId}>{t.help}</desc>
</svg>
{/* Texts sit in HTML over the drawing, so they keep their size when the pad scales */}
{baseline && (
<span aria-hidden className="pointer-events-none absolute text-[15px] leading-none text-subtle-foreground" style={{ left: '6%', top: `calc(74% - 20px)` }}>
×
</span>
)}
{placeholder && !strokes.length && !live && (
<span aria-hidden className="pointer-events-none absolute inset-x-0 top-[38%] text-center text-[13px] text-subtle-foreground transition-opacity duration-300 starting:opacity-0">
{placeholder}
</span>
)}
</div>
);
}
/** The typed alternative: the name large and slanted, in the one typeface of the kit */
function Typed({ name, vw, vh }: { name: string; vw: number; vh: number }) {
const size = Math.min(44, (vw * 0.82) / Math.max(6, name.length * 0.5));
return (
<svg viewBox={`0 0 ${vw} ${vh}`} className="block w-full text-foreground" aria-hidden>
<line x1={vw * 0.06} x2={vw * 0.94} y1={vh * 0.74} y2={vh * 0.74} className="stroke-border-strong" strokeWidth="1" strokeDasharray="4 4" />
<text x={vw * 0.1} y={vh * 0.7} className="fill-current font-medium italic" style={{ fontSize: round(size, 1), letterSpacing: '-0.02em' }}>
{name}
</text>
</svg>
);
}
const ICON_BTN =
'inline-flex h-11 items-center gap-1.5 rounded-md px-2.5 text-[13px] font-medium text-muted-foreground outline-none transition-[background-color,color,scale] duration-150 hover:bg-accent hover:text-foreground focus-visible:bg-accent focus-visible:text-foreground focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] active:scale-95 disabled:pointer-events-none disabled:opacity-40 sm:h-8';
export type SignaturePadVariant = 'draw' | 'contract' | 'initials' | 'dialog';
export interface SignaturePadProps {
variant?: SignaturePadVariant;
/** A label above the pad */
label?: string;
/** Who signs, shown under the line and used for the typed signature */
signer?: string;
/** contract: the text to agree to, a short paragraph per entry */
terms?: string[];
/** contract: the checkbox text */
agreeLabel?: string;
/** initials: one small pad per entry, e.g. section names */
sections?: string[];
/** Offer typing the name instead of drawing. Default true. */
allowTyped?: boolean;
/** draw, contract: strokes already on the pad, in a 600 × 200 space */
defaultValue?: Stroke[];
onChange?: (value: SignatureValue) => void;
/** contract, dialog: called with the final signature */
onSign?: (value: SignatureValue) => void;
labels?: Partial<Texts>;
className?: string;
}
const VW = 600;
const VH = 200;
/** The pad with its toolbar: draw or type, undo, clear */
function SignatureBox({ t, signer, allowTyped, onValue, vw = VW, vh = VH, compact, autoFocusTyped, initial }: { t: Texts; signer?: string; allowTyped: boolean; onValue: (v: SignatureValue) => void; vw?: number; vh?: number; compact?: boolean; autoFocusTyped?: boolean; initial?: Stroke[] }) {
const [strokes, setStrokes] = useState<Stroke[]>(initial ?? []);
const [mode, setMode] = useState<'draw' | 'type'>('draw');
const [typed, setTyped] = useState(signer ?? '');
const inputId = useId();
const emit = useCallback(
(s: Stroke[], m: 'draw' | 'type', name: string) =>
onValue(m === 'type' ? { strokes: [], typed: name, svg: '', empty: !name.trim() } : { strokes: s, svg: s.length ? signatureToSvg(s, vw, vh) : '', empty: !s.length }),
[onValue, vw, vh],
);
const setS = (s: Stroke[]) => {
setStrokes(s);
emit(s, mode, typed);
};
return (
<div className="grid grid-cols-[minmax(0,1fr)] gap-2">
<div className="flex items-center justify-between gap-2">
{allowTyped ? (
<div
role="radiogroup"
aria-label={t.mode}
onKeyDown={(e) => {
// Radio group after the APG: arrows move and choose, one tab stop
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key)) return;
e.preventDefault();
const next = mode === 'draw' ? 'type' : 'draw';
setMode(next);
emit(strokes, next, typed);
const group = e.currentTarget;
requestAnimationFrame(() => group.querySelector<HTMLElement>(`[data-mode="${next}"]`)?.focus());
}}
className="inline-flex h-12 rounded-lg bg-muted p-0.5 sm:h-8"
>
{(['draw', 'type'] as const).map((m) => (
<button
key={m}
type="button"
role="radio"
data-mode={m}
aria-checked={mode === m}
tabIndex={mode === m ? 0 : -1}
onClick={() => {
setMode(m);
emit(strokes, m, typed);
}}
className={cn('inline-flex items-center gap-1.5 rounded-[7px] px-3 text-[12.5px] font-medium outline-none transition-[background-color,color,box-shadow] duration-200 focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]', mode === m ? 'bg-background text-foreground shadow-[var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}
>
<Icon name={m === 'draw' ? 'edit' : 'type'} size={14} />
{m === 'draw' ? t.draw : t.type}
</button>
))}
</div>
) : (
<span />
)}
{mode === 'draw' && (
<span className="flex items-center gap-0.5">
<button type="button" aria-label={t.undo} title={t.undo} disabled={!strokes.length} onClick={() => setS(strokes.slice(0, -1))} className={cn(ICON_BTN, 'w-11 justify-center px-0 sm:w-8')}>
<Icon name="undo" size={15} />
</button>
<button type="button" disabled={!strokes.length} onClick={() => setS([])} className={cn(ICON_BTN, 'max-sm:w-11 max-sm:justify-center max-sm:px-0')}>
<Icon name="eraser" size={15} />
<span className="max-sm:sr-only">{t.clear}</span>
</button>
</span>
)}
</div>
<div className="overflow-hidden rounded-xl border border-border bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--background))] shadow-[inset_0_1px_2px_rgb(0_0_0/0.05)]">
{mode === 'draw' ? (
<Pad strokes={strokes} onStrokes={setS} t={t} vw={vw} vh={vh} placeholder={t.empty} />
) : (
<div className="grid">
<Typed name={typed || ' '} vw={vw} vh={vh} />
<div className="border-t border-border p-2">
<label htmlFor={inputId} className="sr-only">{t.typeLabel}</label>
<input
id={inputId}
autoFocus={autoFocusTyped}
value={typed}
placeholder={t.typeLabel}
onChange={(e) => {
setTyped(e.target.value);
emit(strokes, 'type', e.target.value);
}}
className="h-11 w-full rounded-md bg-transparent px-2.5 text-base text-foreground outline-none placeholder:text-subtle-foreground focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] sm:h-9 sm:text-sm"
/>
</div>
</div>
)}
</div>
{signer && mode === 'draw' && <p className="text-[12.5px] text-muted-foreground">{signer}</p>}
</div>
);
}
/** A finished signature, small, with the date: what a document shows once signed */
function SignedStamp({ value, t, signer, date, vw = VW, vh = VH }: { value: SignatureValue; t: Texts; signer?: string; date: string; vw?: number; vh?: number }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)] gap-2 transition-[opacity,scale] duration-500 ease-[var(--ease-kit)] starting:scale-95 starting:opacity-0">
<div className="relative overflow-hidden rounded-xl border border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_5%,var(--card))]">
{value.typed ? (
<Typed name={value.typed} vw={vw} vh={vh} />
) : (
<svg viewBox={`0 0 ${vw} ${vh}`} className="block w-full text-foreground" aria-hidden>
<g fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
{value.strokes.map((s, i) => {
const p = strokePath(s);
// The ink draws itself once more when the stamp appears
return <path key={i} d={p.d} strokeWidth={round(p.w, 2)} pathLength={1} strokeDasharray="1 1" className="animate-[kitfx-sig-draw_0.9s_var(--ease-kit)_both] motion-reduce:animate-none" style={{ animationDelay: `${i * 120}ms` }} />;
})}
</g>
</svg>
)}
<span className="absolute end-3 top-3 inline-flex items-center gap-1 rounded-md bg-[color-mix(in_oklab,var(--success)_16%,transparent)] px-2 py-1 text-[11.5px] font-medium text-success-foreground">
<Icon name="check" size={13} strokeWidth={2.2} />
{t.signed}
</span>
</div>
<p className="text-[12.5px] text-muted-foreground">
{t.signedOn} {date}
{signer && ` ${t.by} ${signer}`}
</p>
<style href="kitfx-sig" precedence="default">{'@keyframes kitfx-sig-draw{from{stroke-dashoffset:1}to{stroke-dashoffset:0}}'}</style>
</div>
);
}
const today = (locale: 'en' | 'de') => new Date().toLocaleDateString(locale === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' });
/**
* A signature drawn with finger, pen or mouse, or typed. Comes back as SVG.
* <SignaturePad label="Signature" signer="Mara Lindqvist" onChange={(v) => setSig(v)} />
* <SignaturePad variant="contract" terms={paragraphs} agreeLabel="I agree" onSign={send} />
* <SignaturePad variant="initials" sections={['Rent', 'Deposit', 'House rules']} />
* <SignaturePad variant="dialog" label="Signature" signer="Mara Lindqvist" />
*/
export function SignaturePad({ variant = 'draw', label, signer, terms = [], agreeLabel, sections = [], allowTyped = true, defaultValue, onChange, onSign, labels, className }: SignaturePadProps) {
const t = useTexts(TEXTS, labels);
const locale = useLocale();
const [value, setValue] = useState<SignatureValue>(() => (defaultValue?.length ? { strokes: defaultValue, svg: signatureToSvg(defaultValue, VW, VH), empty: false } : { strokes: [], svg: '', empty: true }));
const [agreed, setAgreed] = useState(false);
const [signed, setSigned] = useState<SignatureValue | null>(null);
const [date, setDate] = useState('');
const [tried, setTried] = useState(false);
const labelId = useId();
const take = useCallback(
(v: SignatureValue) => {
setValue(v);
onChange?.(v);
},
[onChange],
);
/* ---------- initials: small pads per section ---------- */
const [inits, setInits] = useState<Record<number, Stroke[]>>({});
if (variant === 'initials') {
const done = sections.filter((_, i) => inits[i]?.length).length;
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-3', className)}>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
<ol className="grid grid-cols-[minmax(0,1fr)] gap-2.5">
{sections.map((s, i) => {
const has = Boolean(inits[i]?.length);
return (
<li key={i} className={cn('flex flex-wrap items-center gap-3 rounded-xl border p-2.5 ps-3.5 transition-[border-color,background-color] duration-300 sm:flex-nowrap', has ? 'border-[color-mix(in_oklab,var(--success)_40%,var(--border))] bg-[color-mix(in_oklab,var(--success)_5%,var(--card))]' : 'border-border bg-card')}>
<span className={cn('grid size-7 shrink-0 place-items-center rounded-full text-[12px] font-semibold tabular-nums transition-colors duration-300', has ? 'bg-success text-[color-mix(in_oklab,var(--background)_95%,transparent)]' : 'bg-muted text-muted-foreground')}>
{has ? <Icon name="check" size={14} strokeWidth={2.4} /> : i + 1}
</span>
<span className="min-w-0 flex-1 text-sm text-foreground">{s}</span>
{/* On phones the pad gets its own full width line under the section name */}
<div className="order-last ms-10 w-[calc(100%-2.5rem)] max-w-[13rem] shrink-0 overflow-hidden rounded-lg border border-dashed border-border-strong bg-background sm:order-none sm:ms-0 sm:w-36">
<Pad strokes={inits[i] ?? []} onStrokes={(st) => setInits((m) => ({ ...m, [i]: st }))} t={{ ...t, area: `${t.initials}, ${s}` }} vw={180} vh={70} baseline={false} placeholder={t.initials} />
</div>
<button type="button" aria-label={`${t.clear}: ${s}`} disabled={!has} onClick={() => setInits((m) => ({ ...m, [i]: [] }))} className={cn(ICON_BTN, 'w-11 justify-center px-0 sm:w-8')}>
<Icon name="eraser" size={15} />
</button>
</li>
);
})}
</ol>
<div className="flex items-center gap-3" aria-live="polite">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-track">
<div className="h-full origin-left rounded-full bg-success transition-[scale] duration-500 ease-[var(--ease-kit)]" style={{ scale: `${sections.length ? round(done / sections.length, 3) : 0} 1` }} />
</div>
<span className="text-[12.5px] tabular-nums text-muted-foreground">
{done}/{sections.length}
</span>
</div>
</div>
);
}
/* ---------- dialog: a field that opens a large pad ---------- */
if (variant === 'dialog') {
return <SignatureDialogField t={t} label={label} signer={signer} allowTyped={allowTyped} onSign={(v) => { onSign?.(v); onChange?.(v); }} className={className} locale={locale} />;
}
/* ---------- contract: read, agree, sign ---------- */
if (variant === 'contract') {
const sign = () => {
setTried(true);
if (value.empty || !agreed) return;
setSigned(value);
setDate(today(locale));
onSign?.(value);
};
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-5', className)}>
{label && <p id={labelId} className="text-sm font-medium text-foreground">{label}</p>}
{terms.length > 0 && (
<div tabIndex={0} aria-labelledby={label ? labelId : undefined} className="kit-scrollbar relative max-h-48 overflow-y-auto rounded-xl border border-border bg-card p-4 text-[13.5px] leading-relaxed text-muted-foreground outline-none focus-visible:shadow-[0_0_0_2px_color-mix(in_oklab,var(--primary)_50%,transparent)] sm:p-5">
{terms.map((p, i) => (
<p key={i} className={cn(i > 0 && 'mt-3')}>{p}</p>
))}
</div>
)}
{signed ? (
<SignedStamp value={signed} t={t} signer={signer} date={date} />
) : (
<>
<label className="flex min-h-11 cursor-pointer items-start gap-3 text-sm text-foreground">
<input type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} className="peer sr-only" />
<span aria-hidden className={cn('mt-0.5 grid size-[18px] shrink-0 place-items-center rounded-[5px] border transition-colors duration-200 peer-focus-visible:shadow-[0_0_0_2px_var(--background),0_0_0_4px_color-mix(in_oklab,var(--primary)_60%,transparent)]', agreed ? 'border-primary bg-primary text-primary-foreground' : 'border-border-strong bg-background', tried && !agreed && 'border-destructive')}>
{agreed && <Icon name="check" size={13} strokeWidth={2.6} />}
</span>
{agreeLabel}
</label>
<SignatureBox t={t} signer={signer} allowTyped={allowTyped} onValue={take} initial={defaultValue} />
{tried && (value.empty || !agreed) && (
<p className="flex items-center gap-1.5 text-[13px] font-medium text-destructive transition-[opacity,translate] duration-300 starting:-translate-y-1 starting:opacity-0" role="alert">
<Icon name="alert-circle" size={15} />
{t.required}
</p>
)}
<button
type="button"
onClick={sign}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] outline-none transition-[background-color,scale,opacity] duration-200 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_35%,transparent)] active:scale-[0.98] sm:justify-self-end"
>
<Icon name="edit" size={16} />
{t.sign}
</button>
</>
)}
</div>
);
}
/* ---------- draw ---------- */
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-2', className)}>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
<SignatureBox t={t} signer={signer} allowTyped={allowTyped} onValue={take} initial={defaultValue} />
</div>
);
}
/** A form field that shows the signature small and opens a large pad in a dialog */
function SignatureDialogField({ t, label, signer, allowTyped, onSign, className, locale }: { t: Texts; label?: string; signer?: string; allowTyped: boolean; onSign: (v: SignatureValue) => void; className?: string; locale: 'en' | 'de' }) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<SignatureValue>({ strokes: [], svg: '', empty: true });
const [final, setFinal] = useState<SignatureValue | null>(null);
const mounted = useMounted();
const triggerRef = useRef<HTMLButtonElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const titleId = useId();
const close = () => {
setOpen(false);
requestAnimationFrame(() => triggerRef.current?.focus());
};
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const first = dialogRef.current?.querySelector<HTMLElement>('button,input');
first?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') close();
if (e.key === 'Tab' && dialogRef.current) {
// Keep the focus inside the dialog
const f = Array.from(dialogRef.current.querySelectorAll<HTMLElement>('button:not([disabled]),input'));
if (!f.length) return;
const i = f.indexOf(document.activeElement as HTMLElement);
if (e.shiftKey && i <= 0) { e.preventDefault(); f[f.length - 1].focus(); }
else if (!e.shiftKey && i === f.length - 1) { e.preventDefault(); f[0].focus(); }
}
};
document.addEventListener('keydown', onKey);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className={cn('grid grid-cols-[minmax(0,1fr)] gap-2', className)}>
{label && <p className="text-sm font-medium text-foreground">{label}</p>}
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
onClick={() => setOpen(true)}
className="group/sig relative flex h-24 w-full items-center justify-center overflow-hidden rounded-xl border border-dashed border-border-strong bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--background))] text-sm text-muted-foreground outline-none transition-[border-color,background-color] duration-200 hover:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_5%,var(--background))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_25%,transparent)]"
>
{final ? (
<span className="absolute inset-0 grid place-items-center px-6">
{final.typed ? <Typed name={final.typed} vw={VW} vh={VH} /> : <span className="w-full max-w-[18rem]" dangerouslySetInnerHTML={{ __html: final.svg.replace('stroke="#111111"', 'stroke="currentColor"') }} />}
<span className="absolute end-2.5 top-2.5 rounded-md bg-background/90 px-2 py-0.5 text-[11.5px] font-medium text-foreground opacity-0 shadow-[var(--shadow-sm)] transition-opacity group-hover/sig:opacity-100 group-focus-visible/sig:opacity-100">{t.change}</span>
</span>
) : (
<span className="flex items-center gap-2 transition-[translate] duration-300 group-hover/sig:-translate-y-0.5">
<Icon name="edit" size={16} />
{t.empty}
</span>
)}
</button>
{final && <p className="text-[12.5px] text-muted-foreground">{t.signedOn} {today(locale)}{signer && ` ${t.by} ${signer}`}</p>}
{mounted && open &&
createPortal(
<>
<div className="fixed inset-0 z-50 bg-scrim transition-opacity duration-300 starting:opacity-0" onClick={close} aria-hidden />
{/* A sheet from below on phones, a centered dialog from 640 up */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="fixed inset-x-0 bottom-0 z-50 grid gap-4 rounded-t-2xl border border-border bg-popover p-5 shadow-[var(--shadow-lg)] transition-[opacity,translate] duration-300 ease-[var(--ease-kit)] starting:translate-y-6 starting:opacity-0 motion-reduce:transition-none sm:bottom-auto sm:left-1/2 sm:top-1/2 sm:w-[min(36rem,calc(100vw-2rem))] sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-2xl sm:p-6 sm:starting:translate-y-[-45%]"
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 id={titleId} className="text-base font-semibold text-foreground">{label ?? t.sign}</h2>
{signer && <p className="mt-0.5 text-[13px] text-muted-foreground">{signer}</p>}
</div>
<button type="button" aria-label={t.cancel} onClick={close} className={cn(ICON_BTN, 'w-11 justify-center px-0 sm:w-8')}>
<Icon name="x" size={16} />
</button>
</div>
<SignatureBox t={t} allowTyped={allowTyped} signer={signer} onValue={setDraft} />
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button type="button" onClick={close} className="inline-flex h-11 items-center justify-center rounded-lg px-4 text-sm font-medium text-foreground outline-none transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)]">
{t.cancel}
</button>
<button
type="button"
disabled={draft.empty}
onClick={() => {
setFinal(draft);
onSign(draft);
close();
}}
className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-medium text-primary-foreground outline-none transition-[background-color,opacity] duration-200 hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))] focus-visible:shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_35%,transparent)] disabled:opacity-45"
>
<Icon name="check" size={16} />
{t.done}
</button>
</div>
</div>
</>,
document.body,
)}
</div>
);
}Häufige Fragen
Wie installiere ich Unterschriftsfeld?
Mit aiw add @ai-website/signature-pad. Der Befehl legt die Datei components/ui/signature-pad.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon und popover. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Unterschriftsfeld?
4 Varianten, Zeichnen, Vertrag, Kürzel und Feld mit Dialog. Du wählst sie mit der Prop variant, zum Beispiel variant="initials".
Kann ich die Texte von Unterschriftsfeld übersetzen?
Ja. Unterschriftsfeld bringt 18 eigene Texte auf Deutsch und Englisch mit, etwa „Unterschriftsfeld“, „Mit Finger, Stift oder Maus unterschreiben.“ und „Zeichnen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Unterschriftsfeld einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Unterschriftsfeld mit aiw_components und installieren sie mit aiw_add @ai-website/signature-pad. Nenn im Prompt die Variante, etwa variant="contract", dann schreibt der Assistent deine Inhalte direkt in die Props.
Kann ich Unterschriftsfeld ohne Code anpassen?
Ja, im Studio. Alle 4 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden SignaturePad-Code zum Kopieren.