Mikkel Manniche
Dansk English Deutsch

Controls

Signature pad

Draw a signature with mouse, finger or pen, or type it, and get a sharp PNG back.

Adapted from Watermelon UI (MIT) and rewritten with keyboard support, labels and reduced motion.

Preview

Usage

signature-pad-demo.tsx
import { SignaturePad } from '@/components/signature-pad'

export default function SignaturePadDemo() {
  return (
    <div className="flex justify-center">
      <SignaturePad onSign={(s) => console.log(s.kind === 'drawn' ? 'PNG ready' : `typed: ${s.name}`)} />
    </div>
  )
}

Source

signature-pad.tsx
// Inspired by Watermelon UI's “Draw signature” (MIT, © 2026 Watermelon Platform Contributors,
// github.com/WatermelonCorp/watermelon-platform). Rewritten with pointer events, a sharp canvas on
// high-density screens, ink in the current text colour, and a typed fallback for keyboard users.
import { Check, Eraser, Keyboard, PenLine } from 'lucide-react'
import { AnimatePresence, MotionConfig, motion } from 'motion/react'
import { useEffect, useRef, useState, type PointerEvent } from 'react'
import { cn } from '@/lib/utils'

export type Signature = { kind: 'drawn'; png: string } | { kind: 'typed'; name: string }

export type SignaturePadProps = {
  onSign: (signature: Signature) => void
  labels?: Partial<Record<'title' | 'clear' | 'done' | 'type' | 'draw' | 'typed' | 'signed' | 'pad', string>>
  className?: string
}

const EN = {
  title: 'Sign here',
  clear: 'Clear',
  done: 'Done',
  type: 'Type instead',
  draw: 'Draw instead',
  typed: 'Your full name',
  signed: 'Signed',
  pad: 'Signature area. Draw with a mouse, finger or pen.',
}

export function SignaturePad({ onSign, labels = {}, className }: SignaturePadProps) {
  const t = { ...EN, ...labels }
  const canvas = useRef<HTMLCanvasElement>(null)
  const last = useRef<{ x: number; y: number } | null>(null)
  const [mode, setMode] = useState<'draw' | 'type'>('draw')
  const [empty, setEmpty] = useState(true)
  const [name, setName] = useState('')
  const [signed, setSigned] = useState<Signature | null>(null)

  // Size the bitmap to the element times the pixel ratio, so the ink stays sharp.
  useEffect(() => {
    const c = canvas.current
    if (!c || mode !== 'draw' || signed) return
    const fit = () => {
      const r = c.getBoundingClientRect()
      const dpr = window.devicePixelRatio || 1
      c.width = Math.round(r.width * dpr)
      c.height = Math.round(r.height * dpr)
      const ctx = c.getContext('2d')!
      ctx.scale(dpr, dpr)
      ctx.lineCap = 'round'
      ctx.lineJoin = 'round'
      ctx.lineWidth = 2.5
      ctx.strokeStyle = getComputedStyle(c).color
      setEmpty(true)
    }
    fit()
    const ro = new ResizeObserver(fit)
    ro.observe(c)
    return () => ro.disconnect()
  }, [mode, signed])

  const point = (e: PointerEvent<HTMLCanvasElement>) => {
    const r = e.currentTarget.getBoundingClientRect()
    return { x: e.clientX - r.left, y: e.clientY - r.top }
  }

  const down = (e: PointerEvent<HTMLCanvasElement>) => {
    e.currentTarget.setPointerCapture(e.pointerId)
    last.current = point(e)
    const ctx = e.currentTarget.getContext('2d')!
    ctx.beginPath()
    ctx.arc(last.current.x, last.current.y, 1.2, 0, Math.PI * 2)
    ctx.fillStyle = ctx.strokeStyle
    ctx.fill()
    setEmpty(false)
  }

  const move = (e: PointerEvent<HTMLCanvasElement>) => {
    if (!last.current) return
    const ctx = e.currentTarget.getContext('2d')!
    const p = point(e)
    ctx.beginPath()
    ctx.moveTo(last.current.x, last.current.y)
    ctx.lineTo(p.x, p.y)
    ctx.stroke()
    last.current = p
  }

  const clear = () => {
    const c = canvas.current
    if (c) c.getContext('2d')!.clearRect(0, 0, c.width, c.height)
    setName('')
    setEmpty(true)
  }

  const done = () => {
    const s: Signature = mode === 'draw' ? { kind: 'drawn', png: canvas.current!.toDataURL('image/png') } : { kind: 'typed', name: name.trim() }
    setSigned(s)
    onSign(s)
  }

  const ready = mode === 'draw' ? !empty : name.trim().length > 1
  const ghost = 'inline-flex min-h-10 items-center gap-1.5 rounded-xl px-3 text-sm text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground'

  return (
    <MotionConfig reducedMotion="user" transition={{ type: 'spring', bounce: 0, duration: 0.5 }}>
      <AnimatePresence mode="popLayout" initial={false}>
        {signed ? (
          <motion.div
            key="signed"
            initial={{ opacity: 0, scale: 0.94 }}
            animate={{ opacity: 1, scale: 1 }}
            className={cn('flex items-center gap-2', className)}
            role="status"
          >
            <span className="inline-flex min-h-11 items-center gap-2 rounded-full bg-foreground px-5 font-medium text-background">
              <Check className="size-4" aria-hidden />
              {t.signed}
            </span>
            <button
              type="button"
              onClick={() => {
                setSigned(null)
                setEmpty(true)
              }}
              aria-label={mode === 'draw' ? t.draw : t.type}
              className="grid size-11 place-items-center rounded-full bg-muted transition-colors duration-150 hover:bg-accent"
            >
              <PenLine className="size-4" aria-hidden />
            </button>
          </motion.div>
        ) : (
          <motion.section
            key="pad"
            initial={{ opacity: 0, y: 8 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: -8 }}
            aria-label={t.title}
            className={cn('w-full max-w-sm rounded-3xl border bg-card p-4 text-card-foreground shadow-sm', className)}
          >
            <header className="mb-3 flex items-center justify-between">
              <h2 className="font-medium">{t.title}</h2>
              <button type="button" className={ghost} onClick={() => setMode(mode === 'draw' ? 'type' : 'draw')}>
                {mode === 'draw' ? <Keyboard className="size-4" aria-hidden /> : <PenLine className="size-4" aria-hidden />}
                {mode === 'draw' ? t.type : t.draw}
              </button>
            </header>

            {mode === 'draw' ? (
              <canvas
                ref={canvas}
                role="img"
                aria-label={t.pad}
                onPointerDown={down}
                onPointerMove={move}
                onPointerUp={() => (last.current = null)}
                onPointerCancel={() => (last.current = null)}
                className="h-44 w-full cursor-crosshair touch-none rounded-2xl border border-dashed bg-background text-foreground"
              />
            ) : (
              <label className="block">
                <span className="sr-only">{t.typed}</span>
                <input
                  autoFocus
                  value={name}
                  onChange={(e) => setName(e.target.value)}
                  placeholder={t.typed}
                  autoComplete="name"
                  className="grid h-44 w-full rounded-2xl border border-dashed bg-background px-4 text-center font-serif text-3xl italic outline-none placeholder:text-base placeholder:not-italic placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
                />
              </label>
            )}

            <footer className="mt-3 flex items-center justify-between">
              <button type="button" className={ghost} onClick={clear}>
                <Eraser className="size-4" aria-hidden />
                {t.clear}
              </button>
              <button
                type="button"
                disabled={!ready}
                onClick={done}
                className="inline-flex min-h-11 items-center gap-2 rounded-full bg-foreground px-5 text-sm font-medium text-background transition-[opacity,transform] duration-150 active:scale-[0.97] disabled:opacity-30"
              >
                <Check className="size-4" aria-hidden />
                {t.done}
              </button>
            </footer>
          </motion.section>
        )}
      </AnimatePresence>
    </MotionConfig>
  )
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/signature-pad.json

With the registry added (components.json)

npx shadcn@latest add @manniche/signature-pad

Files

  • signature-pad.tsx