Skip to content

SignaturePad

Form

Responsive pointer signature capture with typed alternative, undo, clear, and PNG export.

Mouse/pen/touch drawingKeyboard typed signatureUndo and clearPNG exportDisabled captured name
Open preview

Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.

Usage

Capture normalized vector strokes with mouse, pen, or touch, or replace the drawing with a typed name. onValueChange reports completed strokes and typed/clear/undo actions. Pointer cancellation discards the unfinished stroke. The pad owns its state after defaultValue; remount with a new key to load a different record. Export uses a 1200x400 white PNG with dark ink, independent of the current theme. onExport receives a Blob for your own download or storage flow. The component does not establish identity, consent, audit trails, or legal signing workflows.

Import
import { SignaturePad } from "@/components/ui/signature-pad";

Shipped as editable source in src/components/ui/signature-pad.tsx in both apps. Copy the implementation below when moving it to another project. Copy the complete example from Source code for all imports and state. Add "use client" when using hooks or event handlers. See installation and theming for project setup.

Component source

The complete implementation is included in both apps. It uses the React, Radix, Lucide, and shared-core dependencies already installed in Apex. Keep your project’s semantic theme tokens when reusing it.

src/components/ui/signature-pad.tsx
"use client";
import { useId, useRef, useState, type PointerEvent } from "react";
import { Download, Eraser, Undo2 } from "lucide-react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Input } from "@dashboardpack/core/components/ui/input";
import { Label } from "@dashboardpack/core/components/ui/label";
import { cn } from "@dashboardpack/core/lib/utils";
export interface SignaturePoint {
  x: number;
  y: number;
}
export type SignatureStroke = SignaturePoint[];
export interface SignatureValue {
  strokes: SignatureStroke[];
  typedName: string;
}
export interface SignaturePadProps {
  label: string;
  defaultValue?: SignatureValue;
  onValueChange?: (value: SignatureValue) => void;
  onExport?: (blob: Blob) => void;
  disabled?: boolean;
  className?: string;
}
export function SignaturePad({
  label,
  defaultValue,
  onValueChange,
  onExport,
  disabled,
  className,
}: SignaturePadProps) {
  const id = useId();
  const [signature, setSignature] = useState<SignatureValue>(
    defaultValue ?? { strokes: [], typedName: "" },
  );
  const [draft, setDraft] = useState<SignatureStroke>([]);
  const current = useRef<SignatureStroke>([]);
  const pointer = useRef<number | null>(null);
  const [name, setName] = useState(defaultValue?.typedName ?? "");
  const [message, setMessage] = useState("");
  const [exporting, setExporting] = useState(false);
  const hasInk = signature.strokes.length > 0 || !!signature.typedName;
  const update = (next: SignatureValue) => {
    setSignature(next);
    setMessage("");
    onValueChange?.(next);
  };
  const point = (event: PointerEvent<SVGSVGElement>): SignaturePoint => {
    const box = event.currentTarget.getBoundingClientRect();
    return {
      x: Math.max(0, Math.min(1, (event.clientX - box.left) / box.width)),
      y: Math.max(0, Math.min(1, (event.clientY - box.top) / box.height)),
    };
  };
  const finish = (event: PointerEvent<SVGSVGElement>, cancel = false) => {
    if (pointer.current !== event.pointerId) return;
    pointer.current = null;
    if (!cancel && current.current.length)
      update({
        strokes: [...signature.strokes, current.current],
        typedName: "",
      });
    current.current = [];
    setDraft([]);
  };
  const exportPng = () => {
    setExporting(true);
    const canvas = document.createElement("canvas");
    canvas.width = 1200;
    canvas.height = 400;
    const context = canvas.getContext("2d");
    if (!context) {
      setMessage("Image export is unavailable in this browser.");
      setExporting(false);
      return;
    }
    context.fillStyle = "#ffffff";
    context.fillRect(0, 0, 1200, 400);
    context.strokeStyle = "#111827";
    context.fillStyle = "#111827";
    context.lineWidth = 4;
    context.lineCap = "round";
    context.lineJoin = "round";
    if (signature.typedName) {
      context.font = "italic 64px Georgia, serif";
      context.textAlign = "center";
      context.textBaseline = "middle";
      context.fillText(signature.typedName, 600, 200, 1120);
    }
    for (const stroke of signature.strokes) {
      if (!stroke.length) continue;
      context.beginPath();
      context.moveTo(stroke[0].x * 1200, stroke[0].y * 400);
      for (const value of stroke.slice(1))
        context.lineTo(value.x * 1200, value.y * 400);
      if (stroke.length === 1)
        context.lineTo(stroke[0].x * 1200 + 0.1, stroke[0].y * 400);
      context.stroke();
    }
    canvas.toBlob((blob) => {
      setExporting(false);
      if (blob) {
        setMessage("PNG ready.");
        onExport?.(blob);
      } else setMessage("Image export failed. Please try again.");
    }, "image/png");
  };
  return (
    <div className={cn("min-w-0 space-y-3", className)}>
      <p id={`${id}-label`} className="text-sm font-medium">
        {label}
      </p>
      <div className="relative overflow-hidden rounded-xl border bg-background">
        <svg
          viewBox="0 0 600 200"
          preserveAspectRatio="none"
          role="img"
          aria-labelledby={`${id}-label`}
          aria-describedby={`${id}-hint`}
          className={cn(
            "h-48 w-full text-foreground",
            disabled ? "touch-auto opacity-50" : "touch-none cursor-crosshair",
          )}
          onPointerDown={(event) => {
            if (disabled || event.button !== 0 || pointer.current !== null)
              return;
            pointer.current = event.pointerId;
            event.currentTarget.setPointerCapture(event.pointerId);
            current.current = [point(event)];
            setDraft(current.current);
          }}
          onPointerMove={(event) => {
            if (disabled || pointer.current !== event.pointerId) return;
            current.current = [...current.current, point(event)];
            setDraft(current.current);
          }}
          onPointerUp={(event) => finish(event)}
          onPointerCancel={(event) => finish(event, true)}
          onLostPointerCapture={(event) => finish(event, true)}
        >
          <line
            x1="30"
            x2="570"
            y1="160"
            y2="160"
            stroke="currentColor"
            strokeDasharray="4 5"
            opacity="0.15"
          />
          {[...signature.strokes, draft]
            .filter((stroke) => stroke.length)
            .map((stroke, index) => (
              <polyline
                key={index}
                points={
                  stroke.length === 1
                    ? `${stroke[0].x * 600},${stroke[0].y * 200} ${stroke[0].x * 600 + 0.1},${stroke[0].y * 200}`
                    : stroke
                        .map((value) => `${value.x * 600},${value.y * 200}`)
                        .join(" ")
                }
                fill="none"
                stroke="currentColor"
                strokeWidth="2"
                strokeLinecap="round"
                strokeLinejoin="round"
                vectorEffect="non-scaling-stroke"
              />
            ))}
          {signature.typedName && (
            <text
              x="300"
              y="108"
              textAnchor="middle"
              fontFamily="Georgia, serif"
              fontStyle="italic"
              fontSize="32"
              textLength={signature.typedName.length > 28 ? 520 : undefined}
              lengthAdjust="spacingAndGlyphs"
              fill="currentColor"
            >
              {signature.typedName}
            </text>
          )}
        </svg>
        {!hasInk && !draft.length && (
          <span
            aria-hidden
            className="pointer-events-none absolute inset-0 flex items-center justify-center text-sm text-muted-foreground"
          >
            Draw your signature here
          </span>
        )}
      </div>
      <p id={`${id}-hint`} className="text-xs text-muted-foreground">
        Draw with a mouse, pen, or touch. You can also type your name below.
      </p>
      <div className="flex flex-wrap gap-2">
        <Button
          type="button"
          variant="outline"
          size="sm"
          disabled={disabled || !signature.strokes.length}
          onClick={() =>
            update({ ...signature, strokes: signature.strokes.slice(0, -1) })
          }
        >
          <Undo2 className="size-4" />
          Undo stroke
        </Button>
        <Button
          type="button"
          variant="ghost"
          size="sm"
          disabled={disabled || !hasInk}
          onClick={() => {
            update({ strokes: [], typedName: "" });
            setName("");
          }}
        >
          <Eraser className="size-4" />
          Clear signature
        </Button>
        {onExport && (
          <Button
            type="button"
            variant="outline"
            size="sm"
            disabled={disabled || !hasInk || exporting}
            onClick={exportPng}
          >
            <Download className="size-4" />
            {exporting ? "Exporting…" : "Export PNG"}
          </Button>
        )}
      </div>
      <div className="space-y-2">
        <Label htmlFor={id}>Type your name instead</Label>
        <div className="flex flex-wrap gap-2">
          <Input
            id={id}
            value={name}
            maxLength={80}
            disabled={disabled}
            className="min-w-0 flex-1 basis-40"
            autoComplete="name"
            onChange={(event) => setName(event.target.value)}
            placeholder="Your full name"
          />
          <Button
            type="button"
            variant="secondary"
            disabled={disabled || !name.trim()}
            onClick={() => update({ strokes: [], typedName: name.trim() })}
          >
            Use typed signature
          </Button>
        </div>
      </div>
      <p role="status" className="text-xs text-muted-foreground">
        {message ||
          `${signature.typedName ? "Typed signature" : `${signature.strokes.length} strokes`} captured. Nothing is uploaded.`}
      </p>
    </div>
  );
}

API reference

Common props and defaults. Slot-specific props are named below; native elements also accept their standard HTML attributes. The component TypeScript definitions are the complete type reference.

SignaturePad props
PropType / DefaultDescription
labelstring

Default: required

Visible accessible pad name.
defaultValueSignatureValue

Default: empty strokes and name

Initial normalized strokes and typedName. Nonempty typedName represents the typed alternative.
onValueChange(value: SignatureValue) => void

Default: —

Receive captured vector/name state.
onExport(blob: Blob) => void

Default: —

Enable PNG export and receive the created image.
disabledboolean

Default: false

Prevent drawing, typing, clearing, undo, and export.
classNamestring

Default: —

Extend the container with semantic theme classes.

Build with these too