# Signature Pad

> Canvas signature pad using pointer events with undo, clear, pen color/width and PNG download.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/signature-pad
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/signature-pad.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/signature-pad-demo.tsx"
"use client"

import * as React from "react"

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

export default function SignaturePadDemo() {
  const [preview, setPreview] = React.useState<string | null>(null)

  return (
    <div className="w-full max-w-lg rounded-2xl border bg-card p-4 shadow-sm">
      <div className="mb-3">
        <h3 className="text-sm font-semibold">Sign the rental agreement</h3>
        <p className="text-xs text-muted-foreground">Draw your signature below. It never leaves your browser.</p>
      </div>
      <SignaturePad onExport={setPreview} filename="rental-agreement-signature.png" />
      {preview && (
        <div className="mt-4 flex items-center gap-3 rounded-lg border bg-background p-2">
          {/* eslint-disable-next-line @next/next/no-img-element */}
          <img src={preview} alt="Exported signature preview" className="h-12 w-24 rounded border bg-white object-contain" />
          <p className="min-w-0 text-xs text-muted-foreground">PNG exported ({Math.round(preview.length / 1024)} KB data URL).</p>
        </div>
      )}
    </div>
  )
}
```

## Source

```tsx title="components/signature-pad.tsx"
"use client"

import * as React from "react"
import { Download, Eraser, Undo2 } from "lucide-react"

import { cn } from "@/lib/utils"

/** Points and widths are stored relative to the canvas width so strokes scale on resize. */
type Stroke = { color: string; w: number; points: { x: number; y: number }[] }

export type SignatureInk = { name: string; value: string }

const defaultInks: SignatureInk[] = [
  { name: "Black ink", value: "#111827" },
  { name: "Blue ink", value: "#1d4ed8" },
  { name: "Red ink", value: "#b91c1c" },
  { name: "Green ink", value: "#15803d" },
]

function paintStrokes(ctx: CanvasRenderingContext2D, strokes: Stroke[], scale: number) {
  ctx.lineCap = "round"
  ctx.lineJoin = "round"
  for (const stroke of strokes) {
    const pts = stroke.points.map((p) => ({ x: p.x * scale, y: p.y * scale }))
    const width = Math.max(stroke.w * scale, 0.5)
    ctx.strokeStyle = stroke.color
    ctx.fillStyle = stroke.color
    ctx.lineWidth = width
    if (pts.length === 1) {
      ctx.beginPath()
      ctx.arc(pts[0].x, pts[0].y, width / 2, 0, Math.PI * 2)
      ctx.fill()
      continue
    }
    ctx.beginPath()
    ctx.moveTo(pts[0].x, pts[0].y)
    // Quadratic curves through segment midpoints give smooth, hand-drawn looking lines.
    for (let i = 1; i < pts.length - 1; i++) {
      const mx = (pts[i].x + pts[i + 1].x) / 2
      const my = (pts[i].y + pts[i + 1].y) / 2
      ctx.quadraticCurveTo(pts[i].x, pts[i].y, mx, my)
    }
    const last = pts[pts.length - 1]
    ctx.lineTo(last.x, last.y)
    ctx.stroke()
  }
}

export interface SignaturePadProps extends Omit<React.ComponentProps<"div">, "onChange"> {
  inks?: SignatureInk[]
  defaultWidth?: number
  /** Background painted into the exported PNG. Pass null for a transparent PNG. */
  exportBackground?: string | null
  /** File name used by the download button */
  filename?: string
  /** Called with the PNG data URL when the download button is pressed */
  onExport?: (dataUrl: string) => void
  /** Called whenever the signature becomes empty or non-empty */
  onChange?: (isEmpty: boolean) => void
  hint?: string
}

export function SignaturePad({
  inks = defaultInks,
  defaultWidth = 2.5,
  exportBackground = "#ffffff",
  filename = "signature.png",
  onExport,
  onChange,
  hint = "Sign here",
  className,
  ...props
}: SignaturePadProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null)
  const strokesRef = React.useRef<Stroke[]>([])
  const currentRef = React.useRef<Stroke | null>(null)
  const pointerRef = React.useRef<number | null>(null)
  const sizeRef = React.useRef({ w: 0, h: 0, dpr: 1 })
  const hintId = React.useId()

  const [color, setColor] = React.useState(inks[0]?.value ?? defaultInks[0].value)
  const [width, setWidth] = React.useState(defaultWidth)
  const [count, setCount] = React.useState(0)
  const [drawing, setDrawing] = React.useState(false)
  const [status, setStatus] = React.useState("")

  const redraw = React.useCallback(() => {
    const canvas = canvasRef.current
    const ctx = canvas?.getContext("2d")
    if (!canvas || !ctx) return
    const { w, h, dpr } = sizeRef.current
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    ctx.clearRect(0, 0, w, h)
    paintStrokes(ctx, strokesRef.current, w)
  }, [])

  // Keep the backing store in sync with the CSS size and devicePixelRatio.
  React.useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const resize = () => {
      const dpr = window.devicePixelRatio || 1
      const w = canvas.clientWidth
      const h = canvas.clientHeight
      sizeRef.current = { w, h, dpr }
      canvas.width = Math.max(1, Math.round(w * dpr))
      canvas.height = Math.max(1, Math.round(h * dpr))
      redraw()
    }
    const observer = new ResizeObserver(resize)
    observer.observe(canvas)
    return () => observer.disconnect()
  }, [redraw])

  function pointFrom(event: React.PointerEvent<HTMLCanvasElement> | PointerEvent) {
    const rect = canvasRef.current!.getBoundingClientRect()
    return { x: (event.clientX - rect.left) / rect.width, y: (event.clientY - rect.top) / rect.width }
  }

  function onPointerDown(event: React.PointerEvent<HTMLCanvasElement>) {
    if (pointerRef.current !== null) return
    if (event.pointerType === "mouse" && event.button !== 0) return
    event.preventDefault()
    const canvas = event.currentTarget
    canvas.setPointerCapture(event.pointerId)
    pointerRef.current = event.pointerId
    const rect = canvas.getBoundingClientRect()
    const stroke: Stroke = { color, w: width / rect.width, points: [pointFrom(event)] }
    currentRef.current = stroke
    strokesRef.current.push(stroke)
    setDrawing(true)
    redraw()
  }

  function onPointerMove(event: React.PointerEvent<HTMLCanvasElement>) {
    const stroke = currentRef.current
    if (!stroke || pointerRef.current !== event.pointerId) return
    const native = event.nativeEvent
    const events = typeof native.getCoalescedEvents === "function" ? native.getCoalescedEvents() : []
    for (const e of events.length ? events : [native]) {
      const p = pointFrom(e)
      const prev = stroke.points[stroke.points.length - 1]
      if (Math.hypot(p.x - prev.x, p.y - prev.y) > 0.0015) stroke.points.push(p)
    }
    redraw()
  }

  function endStroke(event: React.PointerEvent<HTMLCanvasElement>) {
    if (pointerRef.current !== event.pointerId) return
    pointerRef.current = null
    currentRef.current = null
    const total = strokesRef.current.length
    setDrawing(false)
    setCount(total)
    setStatus(`${total} stroke${total === 1 ? "" : "s"}`)
    if (total === 1) onChange?.(false)
  }

  function undo() {
    if (!strokesRef.current.length) return
    strokesRef.current.pop()
    const total = strokesRef.current.length
    setCount(total)
    setStatus(total ? `Undid last stroke, ${total} left` : "Signature cleared")
    redraw()
    if (total === 0) onChange?.(true)
  }

  function clear() {
    if (!strokesRef.current.length) return
    strokesRef.current = []
    setCount(0)
    setStatus("Signature cleared")
    redraw()
    onChange?.(true)
  }

  function exportPng() {
    if (!strokesRef.current.length) return
    const { w, h, dpr } = sizeRef.current
    const out = document.createElement("canvas")
    out.width = Math.round(w * dpr)
    out.height = Math.round(h * dpr)
    const ctx = out.getContext("2d")
    if (!ctx) return
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
    if (exportBackground) {
      ctx.fillStyle = exportBackground
      ctx.fillRect(0, 0, w, h)
    }
    paintStrokes(ctx, strokesRef.current, w)
    const dataUrl = out.toDataURL("image/png")
    const link = document.createElement("a")
    link.href = dataUrl
    link.download = filename
    document.body.appendChild(link)
    link.click()
    link.remove()
    onExport?.(dataUrl)
    setStatus("Signature exported as PNG")
  }

  const empty = count === 0 && !drawing
  const actionBtn =
    "inline-flex h-9 items-center gap-1.5 rounded-lg border bg-background px-3 text-sm font-medium transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-40"

  return (
    <div className={cn("w-full", className)} {...props}>
      <div className="relative aspect-[5/2] min-h-40 w-full overflow-hidden rounded-xl border bg-white shadow-xs">
        <div aria-hidden className="pointer-events-none absolute inset-x-6 bottom-[28%] flex items-end border-b border-dashed border-slate-300">
          <span className="-mb-px pr-2 text-sm leading-none text-slate-400">×</span>
        </div>
        {empty && (
          <p
            id={hintId}
            className="pointer-events-none absolute inset-0 flex items-center justify-center px-6 text-center text-sm text-slate-400 select-none"
          >
            {hint}
          </p>
        )}
        <canvas
          ref={canvasRef}
          role="img"
          tabIndex={0}
          aria-label="Signature drawing area. Draw with a mouse, finger or pen. Press Control+Z to undo."
          aria-describedby={empty ? hintId : undefined}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endStroke}
          onPointerCancel={endStroke}
          onKeyDown={(event) => {
            if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
              event.preventDefault()
              undo()
            }
          }}
          className="absolute inset-0 block size-full cursor-crosshair touch-none focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-ring/40"
        />
      </div>

      <div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-3">
        <div role="group" aria-label="Pen color" className="flex items-center gap-1.5">
          {inks.map((ink) => (
            <button
              key={ink.value}
              type="button"
              aria-label={ink.name}
              aria-pressed={color === ink.value}
              onClick={() => setColor(ink.value)}
              style={{ backgroundColor: ink.value }}
              className={cn(
                "size-6 rounded-full border border-black/10 transition-transform focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40",
                color === ink.value ? "scale-110 ring-2 ring-foreground ring-offset-2 ring-offset-background" : "hover:scale-110"
              )}
            />
          ))}
        </div>
        <label className="flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
          <span>Width</span>
          <input
            type="range"
            min={1}
            max={8}
            step={0.5}
            value={width}
            aria-label="Pen width"
            aria-valuetext={`${width} pixels`}
            onChange={(event) => setWidth(Number(event.target.value))}
            className="h-6 w-24 min-w-0 accent-primary"
          />
          <span aria-hidden className="w-8 tabular-nums">
            {width}px
          </span>
        </label>
        <div className="flex flex-wrap items-center gap-2 sm:ml-auto">
          <button type="button" onClick={undo} disabled={count === 0} aria-label="Undo last stroke" className={actionBtn}>
            <Undo2 className="size-4" aria-hidden />
            Undo
          </button>
          <button type="button" onClick={clear} disabled={count === 0} aria-label="Clear signature" className={actionBtn}>
            <Eraser className="size-4" aria-hidden />
            Clear
          </button>
          <button
            type="button"
            onClick={exportPng}
            disabled={count === 0}
            aria-label="Download PNG"
            className={cn(actionBtn, "border-transparent bg-primary text-primary-foreground hover:bg-primary/90")}
          >
            <Download className="size-4" aria-hidden />
            Download
          </button>
        </div>
      </div>
      <p role="status" aria-live="polite" className="sr-only">
        {status}
      </p>
    </div>
  )
}
```
