Inputs

Signature pad

A pad to sign with a finger, pen or mouse, with undo, clear, SVG and PNG export and a typed-name fallback.

Built on
SVGPointer eventssetPointerCapturetouch-actionsetCustomValidityCanvas 2D
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
5.3 kB
Built from
Button
Sign the lease
Code
import { SignaturePad } from "@/components/equip/signature-pad";

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <SignaturePad label="Sign the lease" />
    </div>
  );
}

Examples

Export as SVG or PNG

The SVG has the ink colour written in, so it looks the same outside the page; signatureToPng rasterises it at twice the size.

Delivery signature

Draw with a finger, pen or mouse.

Nothing signed yet
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SignaturePad, signatureToPng, type SignatureValue } from "@/components/equip/signature-pad";

export default function Example() {
  const [value, setValue] = useState<SignatureValue | null>(null);
  const [png, setPng] = useState("");
  return (
    <div style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360 }}>
      <SignaturePad
        label="Delivery signature"
        hint="Draw with a finger, pen or mouse."
        onValueChange={(next) => {
          setValue(next);
          setPng("");
        }}
      />
      <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
        <Button variant="outline" size="s" disabled={!value} onClick={async () => value && setPng(await signatureToPng(value.svg))}>
          Make PNG
        </Button>
        <span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
          {value ? `${value.kind === "drawn" ? `${value.paths.length} strokes` : "Typed name"}, ${(value.svg.length / 1000).toFixed(1)} KB of SVG` : "Nothing signed yet"}
        </span>
      </div>
      {png && (
        <img
          src={png}
          alt="Signature as a PNG"
          style={{ width: 200, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-s)" }}
        />
      )}
    </div>
  );
}

Required in a form

Submitting an empty pad focuses the name box, which carries the validity message, so a keyboard user lands where they can sign.

Patient consent

Sign to confirm you have read the treatment plan.

Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { SignaturePad } from "@/components/equip/signature-pad";

export default function Example() {
  const [sent, setSent] = useState("");
  return (
    <form
      noValidate
      style={{ display: "grid", gap: 12, width: "100%", maxWidth: 360 }}
      onSubmit={(event) => {
        event.preventDefault();
        const form = event.currentTarget;
        if (!form.checkValidity()) {
          form.querySelector<HTMLInputElement>(":invalid")?.focus();
          return;
        }
        const svg = String(new FormData(form).get("signature"));
        setSent(`Consent recorded with a ${(svg.length / 1000).toFixed(1)} KB signature.`);
      }}
    >
      <SignaturePad label="Patient consent" name="signature" required hint="Sign to confirm you have read the treatment plan." />
      <Button type="submit" variant="accent">
        Confirm consent
      </Button>
      <p role="status" style={{ margin: 0, minHeight: "1.4em", color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
        {sent}
      </p>
    </form>
  );
}

Error and disabled

Witness signature

The witness must sign before the contract is sent.

Landlord signature

The landlord signs after you.

Code
import { SignaturePad } from "@/components/equip/signature-pad";

export default function Example() {
  return (
    <div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 360 }}>
      <SignaturePad label="Witness signature" error="The witness must sign before the contract is sent." />
      <SignaturePad label="Landlord signature" hint="The landlord signs after you." disabled />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelReactNodenoneVisible label; names the group.
hintReactNodenoneHelp text under the pad, linked to the name box.
errorReactNodenoneError text. Marks the pad and name box invalid and is announced with the name box.
onValueChange(value: SignatureValue | null) => voidnoneCalled after each stroke, undo, clear or typed change with kind, paths, text and svg; null when empty.
namestringnoneAdds a hidden input carrying the SVG document, drawn or typed, so it submits.
requiredbooleanfalseA form will not submit while the pad is empty. The message is set on the name box with setCustomValidity.
disabledbooleanfalseStops drawing and disables the name box and buttons.
typePlaceholderstring"Or type your name"Placeholder of the name box.
...restComponentProps<"div">noneEvery native div attribute on the wrapper, including ref.
signatureToPng(svg: string, scale?: number) => Promise<string>noneExported helper. Rasterises a value's svg to a PNG data URL, at 2x by default. Call it from a handler.

Anatomy

root.eq-signature-padA role=group named by the label.
label.eq-signature-pad-label
surface.eq-signature-pad-surfaceThe bordered paper.
canvas.eq-signature-pad-canvasThe SVG you draw on, a 400 by 160 space; role=img with a name that says what is on it.
baseline.eq-signature-pad-baselineDashed line to sign on.
placeholder.eq-signature-pad-placeholderSign here, shown while empty.
ink.eq-signature-pad-inkGroup holding the strokes.
stroke.eq-signature-pad-strokeOne smoothed path per stroke.
typed.eq-signature-pad-typedThe typed name, drawn in a script face.
bar.eq-signature-pad-barRow with the name box and the buttons.
name.eq-signature-pad-nameText input, the keyboard way to sign.
action.eq-signature-pad-actionThe undo and clear Buttons.
hint.eq-signature-pad-hint
error.eq-signature-pad-error

Keyboard

TabMoves to the name box, then undo and clear. The drawing surface takes no focus.
TypeIn the name box, signs with your typed name, shown in a script face on the pad. This is the keyboard way to sign.
EnterSpaceOn undo or clear, removes the last stroke or everything.

Notes

  • Drawing has no keyboard equivalent, so the name box is always there; a typed name counts as a signature and is exported the same way.
  • Drawing replaces a typed name and typing replaces a drawing, so there is only ever one signature.
  • The surface sets touch-action: none and captures the pointer, so a finger can draw without scrolling the page and a stroke continues outside the pad.
  • Strokes are smoothed with quadratic curves through the pointer samples, including coalesced events where the engine batches them.
  • The exported SVG writes in the ink colour as it was on screen, so it does not depend on the page's styles; signatureToPng draws it on a canvas.
  • When required, an empty pad sets a message on the name box with setCustomValidity, so a failed submit focuses a control the person can use.
  • A drawn or typed signature is a picture of intent, not a cryptographic signature; record who signed and when on your server.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/signature-pad.tsx325 lines
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { ComponentProps, PointerEvent, ReactNode } from "react";
import { Button } from "./button";
import "./signature-pad.css";

/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
  return node != null && node !== false && node !== "";
}

/* Drawing space. The pad keeps this aspect ratio, so strokes never stretch. */
const WIDTH = 400;
const HEIGHT = 160;
const BASELINE = 124;
const STROKE = 2.6;
const SCRIPT = '"Snell Roundhand", "Segoe Script", "Bradley Hand", "Brush Script MT", cursive';

type Point = { x: number; y: number };

export type SignatureValue = {
  /** "drawn" from strokes, or "typed" from the name box. */
  kind: "drawn" | "typed";
  /** One SVG path per stroke, in a 400 by 160 space. Empty when typed. */
  paths: string[];
  /** The typed name. Empty when drawn. */
  text: string;
  /** A standalone SVG document with the ink colour written in. */
  svg: string;
};

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

/** A smooth path through the points: quadratic curves that bend at each point and pass through the midpoints. */
function toPath(points: Point[]) {
  if (points.length === 0) return "";
  const [first] = points;
  // A tap is a dot: a tiny segment that the round cap turns into a circle.
  if (points.length === 1) return `M${round(first.x)} ${round(first.y)}l0.1 0`;
  let d = `M${round(first.x)} ${round(first.y)}`;
  for (let i = 1; i < points.length - 1; i++) {
    const p = points[i];
    const next = points[i + 1];
    d += `Q${round(p.x)} ${round(p.y)} ${round((p.x + next.x) / 2)} ${round((p.y + next.y) / 2)}`;
  }
  const last = points[points.length - 1];
  return `${d}L${round(last.x)} ${round(last.y)}`;
}

const escapeXml = (text: string) =>
  text.replace(/[<>&"']/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" })[c] ?? c);

function toSvg(paths: string[], text: string, color: string) {
  const body = text
    ? `<text x="${WIDTH / 2}" y="${BASELINE - 8}" text-anchor="middle" font-family='${SCRIPT}' font-size="44" fill="${color}">${escapeXml(text)}</text>`
    : `<g fill="none" stroke="${color}" stroke-width="${STROKE}" stroke-linecap="round" stroke-linejoin="round">${paths
        .map((d) => `<path d="${d}"/>`)
        .join("")}</g>`;
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${WIDTH} ${HEIGHT}" width="${WIDTH}" height="${HEIGHT}">${body}</svg>`;
}

/**
 * Rasterises a signature's SVG to a PNG data URL, for storage or a PDF.
 * Runs in the browser only; call it from an event handler.
 */
export async function signatureToPng(svg: string, scale = 2): Promise<string> {
  const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
  try {
    const image = new Image();
    image.decoding = "async";
    image.src = url;
    await image.decode();
    const canvas = document.createElement("canvas");
    canvas.width = WIDTH * scale;
    canvas.height = HEIGHT * scale;
    const context = canvas.getContext("2d");
    if (!context) throw new Error("Canvas 2D is not available.");
    context.drawImage(image, 0, 0, canvas.width, canvas.height);
    return canvas.toDataURL("image/png");
  } finally {
    URL.revokeObjectURL(url);
  }
}

export type SignaturePadProps = Omit<ComponentProps<"div">, "onChange"> & {
  /** Visible label for the pad. */
  label: ReactNode;
  hint?: ReactNode;
  /** Error text. Marks the pad and the name box invalid and is announced with them. */
  error?: ReactNode;
  /** Called after each stroke, undo, clear or typed change; null when the pad is empty. */
  onValueChange?: (value: SignatureValue | null) => void;
  /** Adds a hidden input carrying the SVG document, so the signature submits with a form. */
  name?: string;
  /** A form will not submit while the pad is empty. Reported on the name box, the pad's one focusable control. */
  required?: boolean;
  disabled?: boolean;
  /** Placeholder of the name box, the keyboard way to sign. */
  typePlaceholder?: string;
};

export function SignaturePad({
  label,
  hint,
  error,
  onValueChange,
  name,
  required = false,
  disabled = false,
  typePlaceholder = "Or type your name",
  className,
  ...rest
}: SignaturePadProps) {
  const id = useId();
  const labelId = `${id}-label`;
  const hintId = present(hint) ? `${id}-hint` : undefined;
  const errorId = `${id}-error`;

  const [strokes, setStrokes] = useState<string[]>([]);
  const [live, setLive] = useState<string>("");
  const [typed, setTyped] = useState("");
  const [reported, setReported] = useState(false);
  const [svg, setSvg] = useState("");
  const surface = useRef<SVGSVGElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const points = useRef<Point[] | null>(null);

  const empty = strokes.length === 0 && typed.trim() === "";
  const missing = required && empty;

  // The name box is the control a form can focus, so the empty-pad message lives on it.
  useEffect(() => {
    input.current?.setCustomValidity(missing ? "Draw your signature or type your name." : "");
  }, [missing]);

  function emit(nextStrokes: string[], nextText: string) {
    const text = nextText.trim();
    const isEmpty = nextStrokes.length === 0 && text === "";
    // The ink colour is read at the moment of change, so the exported SVG matches what is on screen.
    const color = surface.current ? getComputedStyle(surface.current).color : "black";
    const doc = isEmpty ? "" : toSvg(text ? [] : nextStrokes, text, color);
    setSvg(doc);
    if (!isEmpty) setReported(false);
    onValueChange?.(
      isEmpty ? null : { kind: text ? "typed" : "drawn", paths: text ? [] : nextStrokes, text, svg: doc },
    );
  }

  function pointFrom(event: PointerEvent<SVGSVGElement>): Point {
    const box = event.currentTarget.getBoundingClientRect();
    return {
      x: ((event.clientX - box.left) / box.width) * WIDTH,
      y: ((event.clientY - box.top) / box.height) * HEIGHT,
    };
  }

  function handlePointerDown(event: PointerEvent<SVGSVGElement>) {
    if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    // Drawing replaces a typed name: one signature at a time.
    if (typed) setTyped("");
    points.current = [pointFrom(event)];
    setLive(toPath(points.current));
  }

  function handlePointerMove(event: PointerEvent<SVGSVGElement>) {
    const current = points.current;
    if (!current) return;
    // Coalesced events keep fast strokes smooth where the engine batches them.
    const batch = event.nativeEvent.getCoalescedEvents?.() ?? [];
    const box = event.currentTarget.getBoundingClientRect();
    const samples = batch.length > 0 ? batch : [event.nativeEvent];
    for (const sample of samples) {
      const p = { x: ((sample.clientX - box.left) / box.width) * WIDTH, y: ((sample.clientY - box.top) / box.height) * HEIGHT };
      const last = current[current.length - 1];
      if (Math.hypot(p.x - last.x, p.y - last.y) >= 1.5) current.push(p);
    }
    setLive(toPath(current));
  }

  function finish() {
    const current = points.current;
    if (!current) return;
    points.current = null;
    setLive("");
    const next = [...strokes, toPath(current)];
    setStrokes(next);
    emit(next, "");
  }

  function undo() {
    const next = strokes.slice(0, -1);
    setStrokes(next);
    emit(next, typed);
    // The button disables itself with the last stroke; keep focus in the pad.
    if (next.length === 0) input.current?.focus();
  }

  function clear() {
    setStrokes([]);
    setTyped("");
    emit([], "");
    input.current?.focus();
  }

  const showMissing = reported && missing;
  const message = present(error) ? error : showMissing ? "Draw your signature or type your name." : null;
  const invalid = present(message) || undefined;
  const describedBy = [hintId, present(message) ? errorId : undefined].filter(Boolean).join(" ") || undefined;
  const typedText = typed.trim();

  return (
    <div
      {...rest}
      className={["eq-signature-pad", className].filter(Boolean).join(" ")}
      role="group"
      aria-labelledby={labelId}
      data-disabled={disabled || undefined}
    >
      <span className="eq-signature-pad-label" id={labelId}>
        {label}
      </span>
      <div className="eq-signature-pad-surface" data-invalid={invalid}>
        <svg
          ref={surface}
          className="eq-signature-pad-canvas"
          viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
          role="img"
          aria-label={empty ? "Signature, empty" : typedText ? `Signature, typed as ${typedText}` : "Signature, drawn"}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={finish}
          onPointerCancel={finish}
          onLostPointerCapture={finish}
        >
          <line className="eq-signature-pad-baseline" x1="24" x2={WIDTH - 24} y1={BASELINE} y2={BASELINE} />
          {typedText ? (
            <text
              className="eq-signature-pad-typed"
              x={WIDTH / 2}
              y={BASELINE - 8}
              textAnchor="middle"
              fontSize="44"
              fontFamily={SCRIPT}
              textLength={typedText.length > 16 ? WIDTH - 64 : undefined}
              lengthAdjust="spacingAndGlyphs"
            >
              {typedText}
            </text>
          ) : (
            <g className="eq-signature-pad-ink" strokeWidth={STROKE}>
              {strokes.map((d, index) => (
                <path key={index} className="eq-signature-pad-stroke" d={d} />
              ))}
              {live && <path className="eq-signature-pad-stroke" d={live} />}
            </g>
          )}
        </svg>
        {empty && !live && (
          <span className="eq-signature-pad-placeholder" aria-hidden="true">
            Sign here
          </span>
        )}
      </div>
      <div className="eq-signature-pad-bar">
        <input
          ref={input}
          className="eq-signature-pad-name"
          type="text"
          autoComplete="name"
          aria-label="Type your name instead of drawing"
          placeholder={typePlaceholder}
          value={typed}
          disabled={disabled}
          // Not the native required: a drawing alone satisfies it, so the check is setCustomValidity above.
          aria-invalid={invalid}
          aria-describedby={describedBy}
          onInvalid={() => setReported(true)}
          onChange={(event) => {
            const text = event.currentTarget.value;
            setTyped(text);
            // Typing replaces any drawing, so there is only ever one signature.
            if (strokes.length > 0 && text.trim()) setStrokes([]);
            emit(text.trim() ? [] : strokes, text);
          }}
        />
        <Button
          className="eq-signature-pad-action"
          variant="ghost"
          size="s"
          square
          aria-label="Undo last stroke"
          disabled={disabled || strokes.length === 0 || typedText !== ""}
          onClick={undo}
          lead={
            <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M5.5 3.5 2.5 6.5l3 3" />
              <path d="M2.5 6.5h7a4 4 0 0 1 0 8h-2" />
            </svg>
          }
        />
        <Button
          className="eq-signature-pad-action"
          variant="ghost"
          size="s"
          disabled={disabled || empty}
          onClick={clear}
        >
          Clear
        </Button>
      </div>
      {present(hint) && (
        <p className="eq-signature-pad-hint" id={hintId}>
          {hint}
        </p>
      )}
      <p className="eq-signature-pad-error" id={errorId} hidden={!present(message)}>
        {message}
      </p>
      {name && <input type="hidden" name={name} value={svg} />}
    </div>
  );
}
components/equip/signature-pad.css154 lines
@layer equip {
  .eq-signature-pad {
    display: grid;
    gap: var(--eq-space-1);
    min-width: 0;
    font-family: var(--eq-font);
    color: var(--eq-ink);
  }

  .eq-signature-pad-label {
    justify-self: start;
    color: var(--eq-ink);
    font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  /* The paper: a bordered box like a text field, taller, keeping the drawing space's 5:2 ratio. */
  .eq-signature-pad-surface {
    position: relative;
    box-sizing: border-box;
    overflow: hidden;
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-signature-pad-surface[data-invalid] {
    border-color: var(--eq-danger);
  }

  .eq-signature-pad-canvas {
    display: block;
    width: 100%;
    height: auto;
    aspect-ratio: 5 / 2;
    color: var(--eq-ink);
    cursor: crosshair;
    /* The browser must not scroll or zoom while a finger draws. */
    touch-action: none;
    user-select: none;
    -webkit-user-select: none;
  }

  .eq-signature-pad-baseline {
    stroke: var(--eq-line-strong);
    stroke-width: 1;
    stroke-dasharray: 4 4;
    vector-effect: non-scaling-stroke;
  }

  /* Text in the page's own size, so it stays readable however small the pad is drawn. Sits on the baseline. */
  .eq-signature-pad-placeholder {
    position: absolute;
    inset-inline-start: 6%;
    inset-block-end: calc(22.5% + var(--eq-space-1));
    color: var(--eq-ink-3);
    font: var(--eq-weight) var(--eq-text-xs) / 1 var(--eq-font);
    pointer-events: none;
  }

  .eq-signature-pad-ink {
    fill: none;
    stroke: currentColor;
    stroke-linecap: round;
    stroke-linejoin: round;
  }

  .eq-signature-pad-typed {
    fill: currentColor;
  }

  /* A typed name lands rather than appears. */
  @starting-style {
    .eq-signature-pad-typed {
      opacity: 0;
    }
  }

  .eq-signature-pad-typed {
    transition: opacity var(--eq-dur) var(--eq-ease);
  }

  .eq-signature-pad-bar {
    display: flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
  }

  /* The keyboard way to sign: a small text field under the paper. */
  .eq-signature-pad-name {
    box-sizing: border-box;
    flex: 1;
    min-width: 0;
    height: var(--eq-h-s);
    margin: 0;
    padding: 0 var(--eq-space-2);
    border: 1px solid var(--eq-line-strong);
    border-radius: var(--eq-radius-s);
    background: var(--eq-raised);
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.2 var(--eq-font);
    transition: border-color var(--eq-dur) var(--eq-ease);
  }

  .eq-signature-pad-name::placeholder {
    color: var(--eq-ink-3);
    opacity: 1;
  }

  .eq-signature-pad-name:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -1px;
  }

  .eq-signature-pad-name[aria-invalid="true"] {
    border-color: var(--eq-danger);
  }

  @media (hover: hover) {
    .eq-signature-pad-name:hover:where(:not(:disabled, [aria-invalid="true"])) {
      border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
    }
  }

  /* Icon-only undo is square; Clear keeps its label. */
  .eq-signature-pad-action {
    flex: none;
  }

  .eq-signature-pad[data-disabled] .eq-signature-pad-surface,
  .eq-signature-pad-name:disabled {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-signature-pad[data-disabled] .eq-signature-pad-canvas {
    cursor: not-allowed;
  }

  .eq-signature-pad-hint,
  .eq-signature-pad-error {
    margin: 0;
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-signature-pad-hint {
    color: var(--eq-ink-2);
  }

  .eq-signature-pad-error {
    color: var(--eq-danger);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index