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
"use client"

import * as React from "react"
import { Eraser, Undo2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { resolveCssColor } from "@/lib/theme-color"

export interface SignaturePadProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange"> {
  /** Fires after every completed stroke with a PNG data URL; `null` after Clear. */
  onChange?: (dataUrl: string | null) => void
  /** Canvas height in px. Width always fills the container. */
  height?: number
  strokeWidth?: number

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/signature-pad.json

Prompt

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 setPointerCapture so 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 carries text-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.

On This Page