Inputs
Signature Pad
A canvas signature capture surface with pointer-drawn strokes, undo/clear, and a PNG data URL export.
Preview in your theme
Loading preview…
- Dependencies
- lucide-react
- Registry dependencies
- utils, theme-color
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/signature-pad.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "SignaturePad" component (lucide-react
Eraser/Undo2, native <canvas>, Pointer Events — no signature-drawing library).
Contract
- Export a forwardRef<HTMLDivElement, SignaturePadProps> extending
HTMLAttributes<HTMLDivElement> (omit native onChange).
- Props: onChange? (dataUrl: string | null => void) — fires after every
completed stroke with a PNG data URL, and with null after Clear or once
Undo empties the history; height (default 160); strokeWidth (default 2);
disabled; exportColor? (ink colour of the exported PNG, any colour string a
canvas accepts; default below); className.
- No imperative ref API: the forwarded ref is the plain container div: Undo
and Clear are built-in buttons, not exposed methods.
- Shared primitive: theme-color (installed automatically via
registryDependencies) — resolves the pad's foreground and background to
channels so the export can pick the darker one. If rebuilding without it:
resolve a CSS colour by painting it into a 1x1 offscreen 2D canvas and
reading the pixel back, with fillStyle reset to "transparent" before every
assignment so an unparsable value reads as alpha 0 instead of the previous
colour.
Behavior
- The canvas fills the container's width. A ResizeObserver on the wrapping
div re-measures width, resizes the canvas's backing store
(devicePixelRatio-scaled) and repaints every stored stroke — the pad
adapts to width changes without stretching or blurring.
- Drawing uses Pointer Events, not mouse/touch events: pointerdown starts a
stroke (for a mouse, only a primary press without Ctrl — a right-click or
a macOS Ctrl-click opens the context menu and must not ink a dot) and calls
setPointerCapture so the drag stays tracked even once the
pointer strays past the canvas edge; pointermove appends points and draws
an incremental line segment; pointerup/pointercancel end the stroke —
never pointerleave, which would fire mid-drag under capture and cut a
large signature short. A mouse pointermove with no button held (buttons
=== 0) ends the stroke too: the pointerup was swallowed elsewhere, and
without this the pad keeps inking on plain hover.
- Strokes are kept as an array of point arrays, not just painted pixels, so
Undo can pop the last stroke and repaint everything from that array, and
so a resize can repaint at the new backing-store size. A stroke that never
moved (a tap) still paints a filled dot, so a signature can include an "i"
dot or a period.
- Ink color is read from getComputedStyle(container).color (the container
carries text-foreground) at the start of every new stroke, so a theme
toggle recolors starting with the very next stroke. Existing strokes
recolor to whatever the current color is whenever a repaint runs (resize,
Undo, Clear) — documented simplification, strokes don't remember the
color they were originally drawn in.
- The exported PNG never reads the visible canvas: every export repaints the
stroke array onto an offscreen canvas (same DPR-scaled size) in the export
ink and calls toDataURL on that. The export ink is exportColor, else the
darker (by luminance) of the container's computed color and
background-color — the foreground in light mode, the background in dark
mode — so a signature drawn in dark mode is still dark ink on transparency
and stays visible on the white contracts, receipts and PDFs it is stored
for. A transparent or unreadable background falls back to the on-screen
ink.
- Undo removes the last stroke and re-emits onChange (a fresh data URL, or
null once nothing is left); Clear wipes every stroke and calls
onChange(null). Both buttons disable themselves when there's nothing to
undo/clear.
- An empty state shows a centered "Sign here" placeholder until the first
stroke, then it disappears.
- disabled stops all drawing and disables both buttons; the whole control
dims via reduced opacity.
- touch-action: none on the canvas stops the page from scrolling while
drawing on a touch device. The ResizeObserver disconnects on unmount.
Rendering & styling
- Semantic tokens only: bg-background / text-foreground / border on the
container, bg-muted/30 + border-t on the toolbar row, text-muted-foreground
for the placeholder and icon buttons. Merge className via cn().
- The canvas is role="img" aria-label="Signature pad" — drawing is
inherently pointer-only, so it is not exposed as a focusable keyboard
control; Undo and Clear are the two real, labelled, keyboard-reachable
<button>s, each with aria-label and a focus-visible ring.
Customization levers
- Height: the height prop (canvas height in px; width always fills the
container).
- Stroke weight: the strokeWidth prop.
- Ink color: swap text-foreground on the container for another token (e.g.
text-chart-2) — the pad always draws in the container's computed color,
so this alone recolors the on-screen ink. The export ink is separate: pass
exportColor (e.g. a brand navy for signed documents).
- Toolbar: swap Eraser/Undo2 for other lucide icons, or reposition the
toolbar div.
- Known limitation: the backing store re-rasterizes on container WIDTH
changes; a DPR-only change (dragging the window to a different-density
display without a width change) keeps the old resolution until the next
resize or stroke — add a matchMedia("(resolution: …dppx)") listener if
that matters to you.
- Not built in (would need real changes, not just props): export formats
other than PNG (re-implement the stroke renderer to trace SVG paths
instead of canvas lines); pressure-sensitive strokes (read e.pressure on
each pointer event and vary lineWidth per segment); a fixed, non-token
on-screen ink color (bypass the getComputedStyle read entirely).Concepts
- Pointer capture, not boundary events — pointerdown calls
setPointerCaptureso drag events keep targeting the canvas even once the cursor strays past its edge; only pointerup/pointercancel end a stroke, never pointerleave. - Stroke history as data, not pixels — every stroke is stored as an array of points, not just painted ink, so Undo and resize can both repaint from that array instead of only ever adding to the canvas.
- Resize-aware backing store — a ResizeObserver on the wrapping element resizes the canvas's actual pixel buffer (devicePixelRatio-scaled) whenever the container's width changes, then repaints the stored strokes so lines stay crisp instead of stretching.
- Foreground-token ink — stroke color is read from the container's computed
color(which carriestext-foreground) at the start of every new stroke, so switching themes recolors ink starting with the very next stroke. - Theme-independent export — the PNG is repainted offscreen in its own ink (
exportColor, else the darker of the pad's foreground and background), so a signature captured in dark mode is not near-white ink that vanishes on a white document. - Tap-to-dot — a stroke that never moved still paints a filled dot at that point, matching how a real pen leaves a mark on a tap.