Skip to content
UI Kit
Accent color

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

4 variants

Variants

Draw

The pad with a dashed line, undo the last stroke or clear it all. Draw or type, the confirm button wakes up once there is a signature.

Open in Studio

Contract

Terms to read in their own scroll area, a box to agree, then the signature. A missing step is named, once signed the ink draws itself again with date and name.

Open in Studio

Initials

A small pad per section for initials, each row turns green with a check, a bar counts how many are done.

Open in Studio

Field with dialog

A field in the form that opens a large pad. Phones get a sheet from below, larger screens a dialog, and the focus stays inside. The signature then sits small in the field.

Open in Studio

Details

What Signature pad brings along, read from the registry and the source.

Variants
4
Version
1.0.0 · since 1.1.0
File
components/ui/signature-pad.tsx
Brings along
utilskiticonpopover
npm packages
None
Languages
English and German, 18 texts of its own

Props

The SignaturePadProps interface from components/ui/signature-pad.tsx, with the comments from the code.

  • variantoptional
    SignaturePadVariant

    drawcontractinitialsdialog

  • labeloptional
    string

    A label above the pad

  • signeroptional
    string

    Who signs, shown under the line and used for the typed signature

  • termsoptional
    string[]

    contract: the text to agree to, a short paragraph per entry

  • agreeLabeloptional
    string

    contract: the checkbox text

  • sectionsoptional
    string[]

    initials: one small pad per entry, e.g. section names

  • allowTypedoptional
    boolean

    Offer typing the name instead of drawing. Default true.

  • defaultValueoptional
    Stroke[]

    draw, contract: strokes already on the pad, in a 600 × 200 space

  • onChangeoptional
    (value: SignatureValue) => void
  • onSignoptional
    (value: SignatureValue) => void

    contract, 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
areaEnglish Signature areaGerman Unterschriftsfeld
helpEnglish Sign with your finger, a pen or the mouse.German Mit Finger, Stift oder Maus unterschreiben.
drawEnglish DrawGerman Zeichnen
typeEnglish TypeGerman Tippen
typeLabelEnglish Your full nameGerman Dein vollständiger Name
clearEnglish ClearGerman Leeren
undoEnglish Undo last strokeGerman Letzten Strich zurücknehmen
emptyEnglish Sign hereGerman Hier unterschreiben
signedEnglish SignedGerman Unterschrieben
signEnglish SignGerman Unterschreiben
changeEnglish Sign againGerman Neu unterschreiben
doneEnglish Use this signatureGerman Diese Unterschrift nutzen
cancelEnglish CancelGerman Abbrechen
signedOnEnglish Signed onGerman Unterschrieben am
byEnglish byGerman von
initialsEnglish InitialsGerman Kürzel
requiredEnglish Please sign before you continue.German Bitte unterschreib, bevor du weitermachst.
modeEnglish 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
components/ui/signature-pad.tsx
'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.

Often searched

Jump to

Copied to the clipboard