Signature pad
A signature drawn with finger, pen or mouse, ink that breathes with the speed, undo and clear, typing as the keyboard alternative, and SVG as the value.
One of 6 components in Special input fields
Variants
Details
What Signature pad brings along, read from the registry and the source.
- Category
- Special input fields
- Area
- Elements
- Variants
- 4
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/signature-pad.tsx
- npm packages
- None
- Languages
- English and German, 18 texts of its own
- Studio
- All 4 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The SignaturePadProps interface from components/ui/signature-pad.tsx, with the comments from the code.
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
Built-in texts
Signature pad writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 18 texts
| Key | English | German |
|---|---|---|
| area | English Signature area | German Unterschriftsfeld |
| help | English Sign with your finger, a pen or the mouse. | German Mit Finger, Stift oder Maus unterschreiben. |
| draw | English Draw | German Zeichnen |
| type | English Type | German Tippen |
| typeLabel | English Your full name | German Dein vollständiger Name |
| clear | English Clear | German Leeren |
| undo | English Undo last stroke | German Letzten Strich zurücknehmen |
| empty | English Sign here | German Hier unterschreiben |
| signed | English Signed | German Unterschrieben |
| sign | English Sign | German Unterschreiben |
| change | English Sign again | German Neu unterschreiben |
| done | English Use this signature | German Diese Unterschrift nutzen |
| cancel | English Cancel | German Abbrechen |
| signedOn | English Signed on | German Unterschrieben am |
| by | English by | German von |
| initials | English Initials | German Kürzel |
| required | English Please sign before you continue. | German Bitte unterschreib, bevor du weitermachst. |
| mode | English How do you want to sign? | German Wie möchtest du unterschreiben? |
Accessibility
Read from the source of the component.
- Signature pad handles the keys Escape, Arrow up, Arrow down, Arrow left, Arrow right, Tab and Space itself.
- Sets aria-checked, aria-describedby, aria-haspopup, aria-label, aria-labelledby, aria-live and aria-modal, so screen readers announce purpose and state.
- Uses the roles alert, dialog, img, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 566 lines
'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>
);
}Frequent questions
How do I install Signature pad?
With aiw add @ai-website/signature-pad. The command puts components/ui/signature-pad.tsx into your project, the code is yours from then on. It brings along icon and popover. It needs no further npm packages.
Which variants does Signature pad have?
4 variants, Draw, Contract, Initials and Field with dialog. You pick one with the variant prop, for example variant="initials".
Can I translate the texts of Signature pad?
Yes. Signature pad carries 18 texts of its own in English and German, such as “Signature area”, “Sign with your finger, a pen or the mouse.” and “Draw”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Signature pad?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Signature pad with aiw_components and install it with aiw_add @ai-website/signature-pad. Name the variant in your prompt, for example variant="contract", and the assistant writes your content straight into the props.
Can I adjust Signature pad without code?
Yes, in the Studio. All 4 variants are editable there, texts, lists and switches. The Studio writes the matching SignaturePad code to copy.