Kontroller
Signature pad
Tegn en underskrift med mus, finger eller pen, eller skriv den, og få en skarp PNG tilbage.
Tilpasset fra Watermelon UI (MIT) og skrevet om med tastatur, etiketter og reduceret bevægelse.
Forhåndsvisning
Brug
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>
)
}Kilde
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ér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/signature-pad.jsonMed registret tilføjet (components.json)
npx shadcn@latest add @manniche/signature-pad