Signature pad
A signature drawn with a mouse, pen or finger — smoothed, undone a stroke at a time — or typed on its own tab, saved as path data or a name that parseSignature checks.
The value is a SignatureValue: { kind: "drawn", paths, width, height } — each stroke as SVG path data on the box it was drawn in — or { kind: "typed", name }. Nothing is an image, so nothing can carry a script or reach another server: a stroke is written in three commands, M, L and Q, with plain numbers, and parseSignature holds a posted one to exactly that — every point inside its box, 1–64 strokes, 16 KB at most — and a typed name to 1–80 printable characters, at least one of them a letter or a digit, with no control, zero-width, filler or right-to-left override characters to make it read other than it prints. A server action calls parseSignature(JSON.parse(formData.get(name))) and refuses null before it stores anything, with the time it was signed. SignatureView draws a saved one as a single named picture — the strokes in the text's own colour, or the typed name in italic — and is server-compatible: import it from signature-pad/signature-view. Drawing captures the pointer, so a stroke that runs off the surface still ends, kept on the edge of the box; strokes are smoothed through their midpoints and thinned as they are drawn, and touch-action is off on the surface, so a finger signs instead of scrolling. A new signature takes the surface's size as its box and later strokes keep it. Undo takes the last stroke, Clear the whole signature. Drawing is pointer-only, so the Type tab is the keyboard's way to sign — a name typed there is the signature, previewed as it will be drawn — and a typed signature opens on that tab. value is controlled whenever the key is present, as DatePicker's is: value={undefined} is an empty pad you hold, which calls back on a stroke and draws nothing until you hand the signature back. name posts the JSON in a hidden input.
Install
npx shadcn@latest add @vibra/signature-padNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { SignaturePad } from "@/components/ui/signature-pad"
import { parseSignature, signatureLabel, type SignatureValue } from "@/components/ui/signature-pad/signature"
export default function SignaturePadDemo() {
const [signature, setSignature] = React.useState<SignatureValue | undefined>(undefined)
const [kept, setKept] = React.useState("")
return (
<form
className="flex w-full max-w-md flex-col gap-3"
onSubmit={(event) => {
event.preventDefault()
// What a check-in action does with the posted field: read it back
// through the same check before it keeps anything.
const posted = String(new FormData(event.currentTarget).get("signature") ?? "")
const parsed = posted ? parseSignature(JSON.parse(posted)) : null
setKept(parsed ? `Checked in. ${signatureLabel(parsed)}.` : "Sign the card first.")
}}
>
<SignaturePad label="Guest signature" name="signature" value={signature} onValueChange={setSignature} />
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="min-w-0 text-xs text-muted-foreground">
{kept || "Room 214, two nights from Friday."}
</p>
<Button type="submit" size="sm">
Check in
</Button>
</div>
</form>
)
}Typed
A signature typed on the keyboard, and drawn by SignatureView where it is shown.
"use client"
import * as React from "react"
import { SignaturePad } from "@/components/ui/signature-pad"
import type { SignatureValue } from "@/components/ui/signature-pad/signature"
import { SignatureView } from "@/components/ui/signature-pad/signature-view"
export default function SignaturePadTyped() {
const [signature, setSignature] = React.useState<SignatureValue | undefined>({ kind: "typed", name: "Aisha Adeyemi" })
return (
<div className="flex w-full max-w-md flex-col gap-3">
<SignaturePad label="Approver signature" value={signature} onValueChange={setSignature} height={120} />
<div className="flex flex-col gap-1">
<p className="type-eyebrow">On the approval</p>
{signature ? (
<SignatureView value={signature} className="max-h-20 w-48" />
) : (
<p className="text-sm text-muted-foreground">Not signed yet.</p>
)}
</div>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | SignatureValue | undefined | — | The signature, controlled whenever the key is present — value={undefined} is an empty pad you hold. |
| defaultValue | SignatureValue | — | Where an uncontrolled pad starts. A typed one opens on the Type tab. |
| onValueChange | (value: SignatureValue | undefined) => void | — | A stroke added or undone, a name typed, or undefined when it is cleared. |
| name | string | — | Posts the signature with its form, as JSON in a hidden input of this name (empty when unsigned). |
| label | string | "Signature" | What the pad is for — "Guest signature"; names the group. |
| height | number | 160 | The drawing surface's height in pixels; its width is the pad's. |
| disabled | boolean | false | No drawing, typing or tab change. |
| className | string | — | Merged onto the root; the rest of the div props are spread there too. |
| SignatureView | React.ComponentProps<"svg"> & { value: SignatureValue; label?: string } | — | From signature-pad/signature-view: a saved signature as one picture, named "Signed by hand" or "Signed as “Name”" unless label says otherwise. |
| parseSignature | (input: unknown) => SignatureValue | null | — | From signature-pad/signature: the check — the signature rebuilt from what was posted, or null. |
| pointsToPath / signatureLabel | (points: { x: number; y: number }[]) => string / (value: SignatureValue) => string | — | A stroke as smoothed path data, and how a signature was given, in words. |
| SIGNATURE_LIMITS | { maxPaths: 64; maxBytes: 16384; maxName: 80 } | — | How much a signature may hold. |
Dependencies
Registry
npm
Source
"use client"
import * as React from "react"
import { EraserIcon, Undo2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { parseSignature, pointsToPath, type SignatureValue } from "./signature"
import { SignatureView } from "./signature-view"
export type SignaturePadProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** The signature, controlled whenever the key is present: `value={undefined}` is an empty pad you hold. */
value?: SignatureValue | undefined
defaultValue?: SignatureValue
onValueChange?: (value: SignatureValue | undefined) => void
/** Posts the signature with its form, as JSON in a hidden input of this name. */
name?: string
/** What the pad is for; names it. */
label?: string
/** The drawing surface's height in pixels. */
height?: number
disabled?: boolean
}
type Point = { x: number; y: number }
type Stroke = { points: Point[]; width: number; height: number; rect: DOMRect }
/** Box units a pointer has to travel before another point is kept: a steady hand adds no noise. */
const STEP = 1.5
/** A side of the drawing box: whole pixels, held to what parseSignature accepts. */
const side = (pixels: number) => Math.min(2000, Math.max(100, Math.round(pixels)))
/** Where the pointer is in the box, kept on its edge when it wanders off the surface. */
function pointAt(event: { clientX: number; clientY: number }, stroke: Stroke): Point {
const x = ((event.clientX - stroke.rect.left) / stroke.rect.width) * stroke.width
const y = ((event.clientY - stroke.rect.top) / stroke.rect.height) * stroke.height
return { x: Math.min(stroke.width, Math.max(0, x)), y: Math.min(stroke.height, Math.max(0, y)) }
}
/**
* A signature drawn with a pointer — smoothed, undone a stroke at a time,
* cleared — or typed on its own tab, which is the keyboard's way to sign.
* Its value is a SignatureValue: path data on the box it was drawn in, or a
* name, which a server reads back through parseSignature.
*/
function SignaturePad(props: SignaturePadProps) {
const { value, defaultValue, onValueChange, name, label = "Signature", height = 160, disabled = false, className, ...rest } = props
// The key, not its value, says who holds the signature (CONVENTIONS: DatePicker).
const controlled = "value" in props
const [internal, setInternal] = React.useState<SignatureValue | undefined>(defaultValue)
const current = controlled ? value : internal
// What the reader typed and the value it made: shown while that value is
// still the signature, so spaces mid-name survive the value coming back
// trimmed, and a value changed from outside shows its own name.
const [draft, setDraft] = React.useState<{ text: string; value: SignatureValue | undefined } | null>(null)
const typed =
draft && JSON.stringify(draft.value ?? null) === JSON.stringify(current ?? null) ? draft.text : current?.kind === "typed" ? current.name : ""
// A typed signature opens on its own tab; the tab is the reader's after that.
const [firstTab] = React.useState(current?.kind === "typed" ? "type" : "draw")
// The stroke being drawn, with the box its points are in — the box a new
// signature takes from the surface before it has a value to hold it.
const [live, setLive] = React.useState<{ points: Point[]; width: number; height: number } | null>(null)
const [refused, setRefused] = React.useState(false)
const stroke = React.useRef<Stroke | null>(null)
const fieldId = React.useId()
function commit(next: SignatureValue | undefined) {
if (!controlled) setInternal(next)
onValueChange?.(next)
}
function keep(event: { clientX: number; clientY: number }) {
const drawing = stroke.current
if (!drawing) return
const point = pointAt(event, drawing)
const last = drawing.points.at(-1)
if (last && Math.hypot(point.x - last.x, point.y - last.y) < STEP) return
drawing.points.push(point)
setLive({ points: [...drawing.points], width: drawing.width, height: drawing.height })
}
function start(event: React.PointerEvent<HTMLDivElement>) {
if (disabled || (event.pointerType === "mouse" && event.button !== 0)) return
const rect = event.currentTarget.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return
event.currentTarget.setPointerCapture?.(event.pointerId)
// A new signature takes the surface's size as its box; a stroke added to
// one keeps the box it was started on, whatever the surface measures now.
const box = current?.kind === "drawn" ? { width: current.width, height: current.height } : { width: side(rect.width), height: side(rect.height) }
stroke.current = { points: [], ...box, rect }
keep(event)
}
function finish(event: React.PointerEvent<HTMLDivElement>) {
const drawing = stroke.current
if (!drawing) return
keep(event)
stroke.current = null
setLive(null)
const before = current?.kind === "drawn" ? current.paths : []
const next = parseSignature({ kind: "drawn", paths: [...before, pointsToPath(drawing.points)], width: drawing.width, height: drawing.height })
// Past 64 strokes or 16 KB the stroke is not kept, and the pad says so.
setRefused(next === null)
if (next) commit(next)
}
const drawn = current?.kind === "drawn" ? current : null
const strokes = drawn?.paths.length ?? 0
const box = live ?? drawn ?? { width: 400, height }
return (
<div
role="group"
aria-label={label}
data-slot="signature-pad"
data-kind={current?.kind ?? "empty"}
data-disabled={disabled || undefined}
className={cn("flex w-full flex-col gap-2", className)}
{...rest}
>
<Tabs defaultValue={firstTab}>
<TabsList aria-label="How to sign">
<TabsTrigger value="draw" disabled={disabled}>
Draw
</TabsTrigger>
<TabsTrigger value="type" disabled={disabled}>
Type
</TabsTrigger>
</TabsList>
<TabsContent value="draw" className="flex flex-col gap-2">
<p className="sr-only">Draw your signature with a mouse, pen or finger. To sign with the keyboard, choose Type.</p>
<div
data-slot="signature-pad-surface"
aria-hidden="true"
style={{ height }}
onPointerDown={start}
onPointerMove={(event) => keep(event)}
onPointerUp={finish}
onPointerCancel={() => {
stroke.current = null
setLive(null)
}}
className={cn(
"relative cursor-crosshair touch-none overflow-hidden rounded-lg border border-input bg-card select-none",
disabled && "cursor-not-allowed bg-muted opacity-50"
)}
>
{/* The rule a signature sits on, and the line that says so, in the quietest ink. */}
<div className="pointer-events-none absolute inset-x-6 bottom-9 border-b border-dashed border-input" />
<span className="pointer-events-none absolute start-6 bottom-3 text-xs text-faint-foreground">Sign above the line</span>
<svg viewBox={`0 0 ${box.width} ${box.height}`} preserveAspectRatio="none" className="absolute inset-0 size-full text-foreground">
{[...(drawn?.paths ?? []), ...(live ? [pointsToPath(live.points)] : [])].map((path, index) => (
<path
key={index}
d={path}
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
</div>
<div className="flex items-center justify-between gap-2">
<p role="status" aria-live="polite" className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
{refused ? (
"That stroke would make the signature too long to keep."
) : strokes > 0 ? (
// A run of its own, left to right: a right-to-left page would turn "3 strokes" round.
<bdi dir="ltr">{`${strokes} ${strokes === 1 ? "stroke" : "strokes"}`}</bdi>
) : null}
</p>
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || !drawn}
onClick={() => {
setRefused(false)
commit(drawn && drawn.paths.length > 1 ? { ...drawn, paths: drawn.paths.slice(0, -1) } : undefined)
}}
>
<Undo2Icon data-icon="inline-start" aria-hidden="true" className="rtl:-scale-x-100" />
Undo
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={disabled || !current}
onClick={() => {
setRefused(false)
setDraft(null)
commit(undefined)
}}
>
<EraserIcon data-icon="inline-start" aria-hidden="true" />
Clear
</Button>
</div>
</div>
</TabsContent>
<TabsContent value="type" className="flex flex-col gap-2">
<Label htmlFor={fieldId}>Type your full name</Label>
<Input
id={fieldId}
value={typed}
autoComplete="name"
disabled={disabled}
onChange={(event) => {
const next = parseSignature({ kind: "typed", name: event.target.value }) ?? undefined
setDraft({ text: event.target.value, value: next })
commit(next)
}}
/>
{current?.kind === "typed" ? <SignatureView value={current} className="panel max-h-24" /> : null}
</TabsContent>
</Tabs>
{name ? <input type="hidden" name={name} value={current ? JSON.stringify(current) : ""} /> : null}
</div>
)
}
export { SignaturePad }/**
* A signature as it is saved: strokes drawn on a box of known size, or a
* typed name. No React in here, so a server action reads a posted signature
* with the same rules the pad wrote it under — `parseSignature` is the check,
* and a stroke is path data in a grammar of three commands and plain
* numbers, which nothing can hide a script or a remote image in.
*/
export type SignatureValue =
| { kind: "drawn"; paths: string[]; width: number; height: number }
| { kind: "typed"; name: string }
/** How much a signature may hold: strokes, characters of path data across them, and characters of a typed name. */
export const SIGNATURE_LIMITS = { maxPaths: 64, maxBytes: 16384, maxName: 80 } as const
const NUMBER = String.raw`\d{1,4}(?:\.\d)?`
const PAIR = `${NUMBER} ${NUMBER}`
/** A stroke: one M, then any run of L and Q segments, every number 0–9999.9 with at most one decimal. */
const STROKE = new RegExp(`^M${PAIR}(?: L${PAIR}| Q${PAIR} ${PAIR})*$`)
const NUMBERS = new RegExp(NUMBER, "g")
/**
* What makes a name read other than it prints: control characters, the soft
* hyphen, the combining grapheme joiner, the Arabic letter mark, the Hangul
* fillers (U+115F, U+1160, U+3164 and the halfwidth U+FFA0), the Mongolian
* vowel separator, zero-width and directional marks, the line and paragraph
* separators, bidirectional embeddings, overrides and isolates, the word
* joiner and the invisible operators, the deprecated format characters, the
* blank braille pattern, the byte order mark and the tag characters. Each
* prints as nothing — or as a space — so a name could hide in them or be
* made of them. Written as escapes so the class can be read.
*/
const UNPRINTABLE =
/[\u0000-\u001F\u007F-\u009F\u00AD\u034F\u061C\u115F\u1160\u180E\u200B-\u200F\u2028-\u202E\u2060-\u2064\u2066-\u206F\u2800\u3164\uFEFF\uFFA0\u{E0000}-\u{E007F}]/u
/** A name says something: at least one letter or digit, in any script. */
const LEGIBLE = /[\p{L}\p{N}]/u
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
/** A side of the drawing box: whole pixels, 100 to 2,000. */
const isSide = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 2000
/** Every point of a stroke inside the box: x up to its width, y up to its height. */
function inBox(stroke: string, width: number, height: number): boolean {
const numbers = (stroke.match(NUMBERS) ?? []).map(Number)
for (let index = 0; index < numbers.length; index += 2) {
if (numbers[index] > width || numbers[index + 1] > height) return false
}
return true
}
/**
* The signature rebuilt from what was posted, or null. A drawn one holds 1–64
* strokes in the path grammar, inside a box of 100–2,000 pixels a side, and
* 16,384 characters of path data at most; a typed one is a name of 1–80
* printable characters once trimmed, at least one of them a letter or a
* digit. Nothing else is copied across.
*/
export function parseSignature(input: unknown): SignatureValue | null {
if (!isRecord(input)) return null
if (input.kind === "typed") {
if (typeof input.name !== "string") return null
const name = input.name.trim()
const length = [...name].length
if (length < 1 || length > SIGNATURE_LIMITS.maxName || UNPRINTABLE.test(name) || !LEGIBLE.test(name)) return null
return { kind: "typed", name }
}
if (input.kind === "drawn") {
const { paths, width, height } = input
if (!Array.isArray(paths) || paths.length < 1 || paths.length > SIGNATURE_LIMITS.maxPaths) return null
if (!isSide(width) || !isSide(height)) return null
// Measured before anything is matched, so an oversized post costs nothing to refuse.
if (!paths.every((path) => typeof path === "string")) return null
if (paths.reduce((total: number, path: string) => total + path.length, 0) > SIGNATURE_LIMITS.maxBytes) return null
if (!paths.every((path: string) => STROKE.test(path) && inBox(path, width, height))) return null
return { kind: "drawn", paths: [...paths] as string[], width, height }
}
return null
}
const round = (value: number) => Math.round(value * 10) / 10
const pair = (point: { x: number; y: number }) => `${round(point.x) || 0} ${round(point.y) || 0}`
/**
* A stroke as path data: from the first point, a quadratic curve through
* each point to the middle of the next segment, then a line to the last —
* smooth where the pointer was jagged — rounded to a tenth of a pixel. One
* point is a dot. Points are taken as they come: keep them inside the box.
*/
export function pointsToPath(points: { x: number; y: number }[]): string {
if (points.length === 0) return ""
if (points.length === 1) return `M${pair(points[0])} L${pair(points[0])}`
let path = `M${pair(points[0])}`
for (let index = 1; index < points.length - 1; index++) {
const next = points[index + 1]
path += ` Q${pair(points[index])} ${pair({ x: (points[index].x + next.x) / 2, y: (points[index].y + next.y) / 2 })}`
}
return `${path} L${pair(points[points.length - 1])}`
}
/** How a signature was given, in words — a drawn one's accessible name. */
export function signatureLabel(value: SignatureValue): string {
return value.kind === "drawn" ? "Signed by hand" : `Signed as “${value.name}”`
}import * as React from "react"
import { cn } from "@/lib/utils"
import { signatureLabel, type SignatureValue } from "./signature"
export type SignatureViewProps = React.ComponentProps<"svg"> & {
value: SignatureValue
/** The picture's name; "Signed by hand" or "Signed as “Name”" by default. */
label?: string
}
/** The typed name's box: wide enough for the name at 32 units, never narrower than a short one needs. */
const typedWidth = (name: string) => Math.max(240, [...name].length * 18 + 48)
/**
* A saved signature as one named picture: the strokes in the text's own
* colour on the box they were drawn in, or a typed name set in italic.
* Server-compatible — no hooks, no client boundary — so a booking's detail
* draws the card it was signed on where it reads it.
*/
function SignatureView({ value, label, className, ...props }: SignatureViewProps) {
const name = label ?? signatureLabel(value)
if (value.kind === "drawn") {
return (
<svg
role="img"
aria-label={name}
data-slot="signature-view"
data-kind="drawn"
viewBox={`0 0 ${value.width} ${value.height}`}
className={cn("h-auto w-full text-foreground", className)}
{...props}
>
{value.paths.map((path, index) => (
<path key={index} d={path} fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
))}
</svg>
)
}
const width = typedWidth(value.name)
return (
<svg
role="img"
aria-label={name}
data-slot="signature-view"
data-kind="typed"
viewBox={`0 0 ${width} 72`}
className={cn("h-auto w-full text-foreground", className)}
{...props}
>
<text x={width / 2} y={40} textAnchor="middle" dominantBaseline="middle" fontSize={32} fontStyle="italic" fill="currentColor">
{value.name}
</text>
</svg>
)
}
export { SignatureView }