SignaturePad
FormResponsive pointer signature capture with typed alternative, undo, clear, and PNG export.
Responsive viewport — resize the window to explore. Direction and density apply only to this preview. Theme applies to the docs too.
"use client";
import { SignaturePad } from "@/components/ui/signature-pad";
import { useState } from "react";
export default function SignaturePadExample() {
const [status, setStatus] = useState("");
return (
<div className="w-full max-w-xl space-y-7">
<div>
<h3 className="font-semibold">Put your name to it</h3>
<p className="mt-1 text-sm text-muted-foreground">
Draw naturally, or use the typed alternative. Export a PNG when you
are ready.
</p>
</div>
<SignaturePad
label="Your signature"
onExport={(blob) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "signature.png";
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
setStatus(`Exported PNG · ${blob.size.toLocaleString()} bytes`);
}}
/>
<p role="status" className="text-xs text-muted-foreground">
{status}
</p>
<SignaturePad
label="Previously captured · disabled"
disabled
defaultValue={{ strokes: [], typedName: "Alex Morgan" }}
/>
<p className="text-xs leading-5 text-muted-foreground">
A capture component for your application. Identity, consent, storage,
and signing workflows belong to the surrounding product.
</p>
</div>
);
}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 { 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.
| Prop | Type / Default | Description |
|---|---|---|
| label | stringDefault: required | Visible accessible pad name. |
| defaultValue | SignatureValueDefault: empty strokes and name | Initial normalized strokes and typedName. Nonempty typedName represents the typed alternative. |
| onValueChange | (value: SignatureValue) => voidDefault: — | Receive captured vector/name state. |
| onExport | (blob: Blob) => voidDefault: — | Enable PNG export and receive the created image. |
| disabled | booleanDefault: false | Prevent drawing, typing, clearing, undo, and export. |
| className | stringDefault: — | Extend the container with semantic theme classes. |