Custom Shadcn Signature Pad for React and Tailwind CSS. A shadcn signature pad with pressure and speed sized ink, touch and stylus input, undo and redo, native form fields and PNG, JPEG, SVG or JSON export.
"use client"import { useId, useRef, useState } from "react"import { Badge } from "@/components/reui/badge"import { SignaturePad, SignaturePadArea,
SignaturePadClear,
SignaturePadControls,
SignaturePadGuide,
SignaturePadPlaceholder,
type SignaturePadApi,
type SignaturePadStroke,
} from "@/components/reui/signature-pad"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { Field, FieldLabel } from "@/components/ui/field"
export function Pattern() {
const id = useId()
const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
const [agreed, setAgreed] = useState(false)
const [signedAt, setSignedAt] = useState<string | null>(null)
const padRef = useRef<SignaturePadApi>(null)
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader>
<CardTitle>Service agreement</CardTitle>
<CardDescription>Northwind Studio, 12-month retainer</CardDescription>
<CardAction>
{/* Up here, away from Sign agreement, so a double click on it
cannot land on Start over and wipe what was just signed. */}
<div className="flex items-center gap-2">
{signedAt && (
<Button
variant="ghost"
size="xs"
onClick={() => {
setSignedAt(null)
setStrokes([])
setAgreed(false)
requestAnimationFrame(() => padRef.current?.focus())
}}
>
Start over
</Button>
)}
<Badge variant={signedAt ? "success-light" : "warning-light"}>
{signedAt ? "Signed" : "Awaiting signature"}
</Badge>
</div>
</CardAction>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
By signing you accept the scope, the payment schedule and the 30-day
notice period in sections 2 to 5.
</p>
<SignaturePad
apiRef={padRef}
value={strokes}
onValueChange={setStrokes}
readOnly={signedAt !== null}
>
<SignaturePadArea aria-label="Client signature" className="h-36">
<SignaturePadGuide />
<SignaturePadPlaceholder />
{signedAt === null && (
<SignaturePadControls position="top-end">
<SignaturePadClear variant="ghost" />
</SignaturePadControls>
)}
</SignaturePadArea>
<div className="text-muted-foreground flex justify-between text-xs">
<span>Maya Chen, Client</span>
<span>{signedAt ?? "Dated on signing"}</span>
</div>
</SignaturePad>
<Field orientation="horizontal">
<Checkbox
id={`${id}-agree`}
checked={agreed}
disabled={signedAt !== null}
onCheckedChange={(checked) => setAgreed(checked === true)}
/>
<FieldLabel htmlFor={`${id}-agree`}>
I have read and agree to the agreement
</FieldLabel>
</Field>
</div>
</CardContent>
<CardFooter>
{signedAt ? (
<p
ref={(node) => node?.focus()}
tabIndex={-1}
role="status"
className="w-full text-center text-sm font-medium outline-none"
>
Agreement signed
</p>
) : (
<Button
className="w-full"
disabled={!agreed || strokes.length === 0}
onClick={() =>
setSignedAt(
new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
}).format(new Date())
)
}
>
Sign agreement
</Button>
)}
</CardFooter>
</Card>
)
}
Signature in a dialog
"use client"import { useRef, useState } from "react"import { SignaturePad, SignaturePadArea, SignaturePadClear,
SignaturePadControls,
SignaturePadGuide,
SignaturePadPlaceholder,
SignaturePadPreview,
SignaturePadUndo,
type SignaturePadStroke,
} from "@/components/reui/signature-pad"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemFooter,
ItemTitle,
} from "@/components/ui/item"
export function Pattern() {
const [open, setOpen] = useState(false)
const [signature, setSignature] = useState<SignaturePadStroke[]>([])
/* Edits land in a draft, so Cancel leaves the saved signature untouched. */
const [draft, setDraft] = useState<SignaturePadStroke[]>([])
const editRef = useRef<HTMLButtonElement>(null)
return (
<>
<Item variant="outline" className="mx-auto w-full max-w-lg">
<ItemContent>
<ItemTitle>Authorized signature</ItemTitle>
<ItemDescription>
Printed on invoices and purchase orders.
</ItemDescription>
</ItemContent>
<ItemActions>
<Button
ref={editRef}
variant="outline"
size="sm"
onClick={(event) => {
/* Safari does not focus a clicked button; focusing it here gives
both dialog libraries the same element to return focus to. */
event.currentTarget.focus()
setDraft(signature)
setOpen(true)
}}
>
{signature.length > 0 ? "Edit" : "Add signature"}
</Button>
</ItemActions>
{signature.length > 0 && (
<ItemFooter>
<SignaturePadPreview strokes={signature} className="h-10 w-auto" />
<Button
variant="ghost"
size="sm"
onClick={() => {
setSignature([])
/* Remove unmounts itself, so focus moves to the row's action. */
editRef.current?.focus()
}}
>
Remove
</Button>
</ItemFooter>
)}
</Item>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Draw your signature</DialogTitle>
<DialogDescription>
Saved to your profile and reused on new documents.
</DialogDescription>
</DialogHeader>
<SignaturePad value={draft} onValueChange={setDraft}>
<SignaturePadArea variant="muted" className="h-48">
<SignaturePadGuide />
<SignaturePadPlaceholder />
<SignaturePadControls>
<SignaturePadUndo />
<SignaturePadClear />
</SignaturePadControls>
</SignaturePadArea>
</SignaturePad>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
disabled={draft.length === 0}
onClick={() => {
setSignature(draft)
setOpen(false)
}}
>
Save signature
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}
Stylus mode with palm rejection
"use client"import { useId, useState } from "react"import { Badge } from "@/components/reui/badge"import { SignaturePad, SignaturePadArea,
SignaturePadClear,
SignaturePadControls,
SignaturePadGuide,
SignaturePadPlaceholder,
type SignaturePadPointerType,
} from "@/components/reui/signature-pad"
import {
Field,
FieldContent,
FieldDescription,
FieldLabel,
} from "@/components/ui/field"
import { Switch } from "@/components/ui/switch"
const INPUT_LABEL: Record<SignaturePadPointerType, string> = {
pen: "Stylus, width from pressure",
touch: "Finger, width from speed",
mouse: "Mouse, width from speed",
}
export function Pattern() {
const id = useId()
const [penOnly, setPenOnly] = useState(false)
const [lastInput, setLastInput] = useState<SignaturePadPointerType | null>(
null
)
return (
<SignaturePad
/* A stylus reports pressure; accepting only it ignores a resting palm. */
pointerTypes={penOnly ? ["pen"] : undefined}
onStrokeStart={({ pointerType }) => setLastInput(pointerType)}
className="mx-auto max-w-lg"
>
<Field orientation="horizontal">
<FieldContent>
<FieldLabel htmlFor={`${id}-pen`}>Pen only</FieldLabel>
<FieldDescription>
Ignore touch and mouse, so a hand resting on the screen leaves no
marks.
</FieldDescription>
</FieldContent>
<Switch
id={`${id}-pen`}
checked={penOnly}
onCheckedChange={(checked) => setPenOnly(checked)}
/>
</Field>
<SignaturePadArea className="h-48">
<SignaturePadGuide />
<SignaturePadPlaceholder>
{penOnly
? "Sign with a stylus"
: "Sign with a stylus, finger or mouse"}
</SignaturePadPlaceholder>
<SignaturePadControls position="top-end">
<SignaturePadClear variant="ghost" />
</SignaturePadControls>
</SignaturePadArea>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
Last stroke
<Badge variant="outline">
{lastInput ? INPUT_LABEL[lastInput] : "None yet"}
</Badge>
</div>
</SignaturePad>
)
}
Initials per clause
"use client"import { useState } from "react"import { Badge } from "@/components/reui/badge"import { SignaturePad, SignaturePadArea,
SignaturePadClear,
SignaturePadControls,
SignaturePadPlaceholder,
} from "@/components/reui/signature-pad"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemMedia,
ItemTitle,
} from "@/components/ui/item"
import { CircleCheckIcon, CircleDashedIcon } from 'lucide-react'
const CLAUSES = [
{
id: "scope",
number: 2,
title: "Scope of work",
description: "Design and build of the marketing site, 6 templates.",
},
{
id: "payment",
number: 3,
title: "Payment terms",
description: "Invoiced monthly in arrears, due within 30 days.",
},
{
id: "ownership",
number: 4,
title: "Ownership",
description: "Source files transfer to the client on final payment.",
},
{
id: "termination",
number: 5,
title: "Termination",
description: "Either party, with 30 days written notice.",
},
]
export function Pattern() {
const [initialed, setInitialed] = useState<Record<string, boolean>>({})
const [submitted, setSubmitted] = useState(false)
const count = CLAUSES.filter((clause) => initialed[clause.id]).length
const complete = count === CLAUSES.length
return (
<div className="mx-auto flex w-full max-w-lg flex-col gap-3">
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Initial each clause</p>
<p className="text-muted-foreground text-xs">
Your initials confirm you have read the section.
</p>
</div>
<Badge variant={complete ? "success-light" : "secondary"}>
{count} of {CLAUSES.length} initialed
</Badge>
</div>
<ItemGroup>
{CLAUSES.map((clause) => {
const done = initialed[clause.id] === true
return (
<Item key={clause.id} variant="outline" size="sm">
<ItemMedia variant="icon" aria-hidden="true">
{done ? (
<CircleCheckIcon className="text-success" />
) : (
<CircleDashedIcon className="text-muted-foreground" />
)}
</ItemMedia>
<ItemContent>
<ItemTitle>
{clause.number}. {clause.title}
{done && <span className="sr-only">, initialed</span>}
</ItemTitle>
<ItemDescription>{clause.description}</ItemDescription>
</ItemContent>
<ItemActions>
{/* Initials are small, so the line range is narrower too. */}
<SignaturePad
minWidth={0.6}
maxWidth={2.4}
readOnly={submitted}
className="w-28"
onValueChange={(strokes) =>
setInitialed((current) => ({
...current,
[clause.id]: strokes.length > 0,
}))
}
>
<SignaturePadArea
variant="muted"
aria-label={`Initials for ${clause.title}`}
className="h-14"
>
<SignaturePadPlaceholder>Initials</SignaturePadPlaceholder>
{done && !submitted && (
<SignaturePadControls className="end-1 top-1">
<SignaturePadClear variant="ghost" size="icon-xs" />
</SignaturePadControls>
)}
</SignaturePadArea>
</SignaturePad>
</ItemActions>
</Item>
)
})}
</ItemGroup>
{submitted ? (
<p
ref={(node) => node?.focus()}
tabIndex={-1}
role="status"
className="self-end text-sm font-medium outline-none"
>
Initials submitted
</p>
) : (
<Button
disabled={!complete}
onClick={() => setSubmitted(true)}
className="self-end"
>
Submit initials
</Button>
)}
</div>
)
}
Proof of delivery
"use client"import { useId, useState } from "react"import { Badge } from "@/components/reui/badge"import { SignaturePad, SignaturePadArea,
SignaturePadClear,
SignaturePadControls,
SignaturePadGuide,
SignaturePadPlaceholder,
SignaturePadUndo,
type SignaturePadStroke,
} from "@/components/reui/signature-pad"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
export function Pattern() {
const id = useId()
const [recipient, setRecipient] = useState("")
const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
const [confirmed, setConfirmed] = useState(false)
return (
<Card className="mx-auto w-full max-w-sm">
<CardHeader>
<CardTitle>Proof of delivery</CardTitle>
<CardDescription>
<span className="flex items-center gap-2">
Order #48213
<span
aria-hidden="true"
className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
/>
3 parcels
</span>
</CardDescription>
{confirmed && (
<CardAction>
<Badge variant="success-light">Delivered</Badge>
</CardAction>
)}
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor={`${id}-recipient`}>Received by</FieldLabel>
<Input
id={`${id}-recipient`}
value={recipient}
disabled={confirmed}
onChange={(event) => setRecipient(event.target.value)}
placeholder="Recipient name"
autoComplete="off"
/>
</Field>
<Field>
<FieldLabel id={`${id}-signature`}>Recipient signature</FieldLabel>
{/* The area blocks page scroll under a finger, so the recipient can sign on a phone. */}
<SignaturePad
value={strokes}
onValueChange={setStrokes}
readOnly={confirmed}
>
<SignaturePadArea
aria-labelledby={`${id}-signature`}
className="h-44"
>
<SignaturePadGuide />
<SignaturePadPlaceholder>
Hand the device to the recipient
</SignaturePadPlaceholder>
{!confirmed && (
<SignaturePadControls position="top-end">
<SignaturePadUndo variant="ghost" />
<SignaturePadClear variant="ghost" />
</SignaturePadControls>
)}
</SignaturePadArea>
</SignaturePad>
</Field>
</div>
</CardContent>
<CardFooter>
{confirmed ? (
<p
ref={(node) => node?.focus()}
tabIndex={-1}
role="status"
className="w-full text-center text-sm font-medium outline-none"
>
Delivered to {recipient.trim()}
</p>
) : (
<Button
size="lg"
className="w-full"
disabled={!recipient.trim() || strokes.length === 0}
onClick={() => setConfirmed(true)}
>
Confirm delivery
</Button>
)}
</CardFooter>
</Card>
)
}
Upload with apiRef
"use client"import { useRef, useState } from "react"import { SignaturePad, SignaturePadArea, SignaturePadGuide,
SignaturePadPlaceholder,
type SignaturePadApi,
} from "@/components/reui/signature-pad"
import { Button } from "@/components/ui/button"
export function Pattern() {
/* `apiRef` reaches the pad from code outside its tree, such as a form
library's submit handler. */
const padRef = useRef<SignaturePadApi>(null)
const [upload, setUpload] = useState<{ name: string; size: number } | null>(
null
)
const [empty, setEmpty] = useState(true)
const prepareUpload = async () => {
const blob = await padRef.current?.toBlob({ scale: 3 })
if (!blob) return
const file = new File([blob], "signature.png", { type: "image/png" })
/* Append `file` to a FormData and POST it to your own endpoint. */
setUpload({ name: file.name, size: file.size })
}
return (
<div className="mx-auto flex w-full max-w-lg flex-col gap-3">
<SignaturePad
apiRef={padRef}
onValueChange={(strokes) => {
setEmpty(strokes.length === 0)
setUpload(null)
}}
>
<SignaturePadArea className="h-44">
<SignaturePadGuide />
<SignaturePadPlaceholder />
</SignaturePadArea>
</SignaturePad>
<div className="flex items-center gap-2">
<Button disabled={empty} onClick={prepareUpload}>
Attach as PNG
</Button>
<Button
variant="ghost"
disabled={empty}
onClick={() => {
padRef.current?.clear()
/* This button disables itself, so focus moves to the pad. */
padRef.current?.focus()
}}
>
Clear
</Button>
<p
role="status"
className="text-muted-foreground ms-auto truncate text-sm"
>
{upload && `${upload.name}, ${(upload.size / 1024).toFixed(1)} KB`}
</p>
</div>
</div>
)
}
API Reference
Signature Pad is composable: SignaturePad holds the strokes and the undo
history, SignaturePadArea is the drawing surface, and every other part is
optional. Children of the area are overlays. A stroke only starts on the area
itself, so a pinned control never leaves ink under the finger that pressed it,
and a custom overlay that is not a control needs pointer-events-none. Ink is
SVG, so it stays sharp and follows the theme; a canvas is created only for PNG
or JPEG output. The only dependencies are the cn utility and the shadcn
Button.
Value
The value is an array of strokes. Each point is [x, y, size]: its position in
CSS pixels from the area's top-left corner, and the ink diameter there. Store it
as JSON to redraw or edit a signature later.
onValueChange fires once per finished stroke and on clear, undo and redo,
never per pointer move. Strokes are compared by content, so a fresh copy of the
same value, the kind react-hook-form hands back, keeps the undo history. A value
that really differs, such as one set from outside, clears it.
Fires when a stroke begins, with the pointer that drew it.
onStrokeEnd
(stroke: SignaturePadStroke) => void
-
Fires with each finished stroke.
apiRef
Ref<SignaturePadApi>
-
The pad's API, for code outside the tree.
color
string
-
Ink color, recorded on each stroke. Unset, ink follows the area's text color.
minWidth
number
0.8
Thinnest ink, in CSS pixels. Equal to maxWidth for a constant line.
maxWidth
number
3.2
Thickest ink, in CSS pixels.
smoothing
number
0.5
From 0 (raw input) to 1. Higher is smoother; strokes still end where the pointer lifts.
sizing
"auto" | "pressure" | "velocity"
"auto"
Width source. auto uses pen pressure for a stylus and drawing speed otherwise.
pointerTypes
("mouse" | "pen" | "touch")[]
all
Accept only these pointers, e.g. ["pen"] to ignore a resting palm.
disabled
boolean
false
Stops input, dims the pad and removes it from the tab order.
readOnly
boolean
false
Stops input, keeps the ink at full strength and removes the pad from the tab order.
name
string
-
Submits the signature with a native form under this name.
form
string
-
Associates the field with a form elsewhere in the page.
required
boolean
false
Fails native form validation while the pad is empty.
format
"png" | "jpeg" | "svg" | "json"
"svg"
What the form field carries. png and jpeg rasterize after every stroke.
SignaturePadArea
The drawing surface, and a focusable application region. Accepts every div
prop, including aria-labelledby, aria-describedby and aria-invalid. Size
it with a class: h-40 is the default height.
Prop
Type
Default
Description
variant
"default" | "muted" | "ghost"
"default"
default matches the shadcn Textarea, muted is a filled surface, ghost has no chrome.
SignaturePadControls
Pins its children to a corner of the area, above the ink. The default keeps
controls off the signing line.
shadcn Buttons wired to the pad. Each disables itself when it has nothing to
do, and hands focus to the area when its own action leaves it disabled. They
default to variant="outline" and size="icon-sm" with an icon; pass text as
children and a text size for a text button.
SignaturePadSave
A shadcn Button that serializes the pad. Disabled while the pad is empty.
For an upload, a Blob skips the base64 inflation of a data URL:
const padRef = useRef<SignaturePadApi>(null)async function upload() { const blob = await padRef.current?.toBlob() if (!blob) return const body = new FormData() body.append("signature", blob, "signature.png") await fetch("/api/signatures", { method: "POST", body })}
SignaturePadExportOptions
Exports crop to the ink with padding by default. Through the hook or apiRef,
crop: false frames the whole area as rendered; the standalone helpers have no
area to measure, so there it keeps the origin and ends at the ink unless you
pass width and height. Ink without its own color exports black, because an
export usually lands on paper.
Option
Type
Default
Description
padding
number
8
Space around the ink when cropping.
crop
boolean
true
false keeps the area's origin.
width
number
-
With height, a fixed frame from the origin.
height
number
-
With width, a fixed frame from the origin.
color
string
"#000000"
Ink for strokes without their own color.
background
string
transparent
Fill behind the ink. JPEG defaults to white.
scale
number
2
Raster only. Pixels per CSS pixel.
type
"image/png" | "image/jpeg" | "image/webp"
"image/png"
Raster only.
quality
number
-
Raster only, for lossy types.
Helpers
Pure functions over a stroke array, for client code that never mounts a pad: a
list of saved signatures, an upload handler, a print view. They ship in the same
"use client" module as the pad, so a Server Component cannot call them. The
raster ones need a browser.
Helper
Returns
signaturePadToSVG(strokes, options)
An SVG document string.
signaturePadToDataURL(strokes, options)
A PNG data URL, or options.type. Browser only.
signaturePadToBlob(strokes, options)
Promise<Blob | null>. Browser only.
serializeSignaturePad(strokes, format, options)
The form field value, SVG by default; "" when empty. png and jpeg need a browser.
getSignaturePadBounds(strokes)
The ink's box, or null without ink.
getSignaturePadStrokePath(stroke)
SVG path data for one stroke's outline.
Forms
Give the root a name and the signature submits with a native form as an SVG
data URL, or as format says. The field updates after every stroke, so keep it
svg or json and rasterize once at submit when you need an image. An empty
pad submits an empty string, so required works. A form reset restores
defaultValue, empty unless you passed one, as a native field returns to its
default. A listener that cancels the reset keeps the ink.
On macOS, ⌘ works in place of Ctrl. The shortcuts follow
the physical key on layouts that type no Latin letter.
Accessibility
The area is a focusable application region with the role description
"signature pad". It is labelled "Signature pad" unless you pass aria-label
or aria-labelledby, and it takes aria-describedby and aria-invalid like
any form control.
The pad describes its own state, "Empty" or "Signed" and whether it is read
only, after any aria-describedby you pass.
The area blocks page scrolling and the long-press menu under a finger, only
the first touch draws, and a pen landing during a touch stroke discards it as
a resting palm.
Drawing needs a pointer. Offer another route to the same outcome, such as a
typed name, wherever a signature is required.
A control that disables itself hands focus to the area instead of dropping it
on the page.
A failed required check focuses the pad, not the hidden field behind it.
The browser's own error bubble is anchored to that hidden field, so for an
error assistive tech announces, validate yourself and pair aria-invalid
with a described-by message, as the form field example does.
Shadcn Signature Pad Free Components
Browse 12 production-ready Shadcn Signature Pad components for dashboards, forms, and product UI. These examples use Base UI primitives from @base-ui/react and stay fully compatible with Shadcn Create so radius, color, and typography match your configured theme.