Skip to content
DocsSupportPricing
Roadmap (has updates coming soon)XFigma3.6K
Sign inGet All-Access
Overview
  • Introduction
  • Get Started
  • License Setup
  • Styling
  • Registry
  • MCP Server
  • Embed
  • Agent Skills
  • llms.txt
  • RTL
  • Changelog
  • YouTube
MCP Server
  • Claude
  • CodexCodexCodex
  • Cursor
  • Grok
  • Conductor
  • v0
  • Lovable
  • Replit
  • Bolt
  • OpenCode
  • VS Code
  • GitHub Copilot
  • Kilo Code
  • Zed
  • Antigravity
  • WSWindsurf
  • CLCline
  • Gemini CLI
  • AMAmp
  • JBJetBrains Junie
Components
  • Alert
  • Autocomplete
  • Badge
  • CascaderCascader can now search the whole tree from any level
  • Code Block
  • Data GridData Grid now scrolls in RTL without an extra provider
  • Date Selector
  • Event Calendar
  • File Upload
  • Filters
  • Frame
  • Gantt
  • Icon Stack
  • Icon Tile
  • Kanban
  • Number Field
  • Phone Input
  • Rating
  • Scrollspy
  • Signature PadNew Signature Pad docs
  • Sortable
  • Stepper
  • Time PickerNew Time Picker docs
  • Timeline
  • Tree

Shadcn Signature Pad

PreviousNext

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.

Base UIRadix UI
"use client"

import { useState } from "react"
import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadControls,
  SignaturePadPlaceholder,
  SignaturePadPreview,
  SignaturePadSave,
  type SignaturePadStroke,
} from "@/components/reui/signature-pad"

export function Pattern() {
  const [saved, setSaved] = useState<SignaturePadStroke[]>([])

  return (
    <div className="mx-auto flex w-full max-w-lg flex-col gap-3">
      <SignaturePad>
        <SignaturePadArea variant="muted" className="h-60">
          <SignaturePadPlaceholder />
          <SignaturePadControls position="bottom-end">
            <SignaturePadClear />
            <SignaturePadSave
              format="json"
              onSave={(_, strokes) => setSaved(strokes)}
            />
          </SignaturePadControls>
        </SignaturePadArea>
      </SignaturePad>
      <div role="status">
        {saved.length > 0 && (
          <div className="text-muted-foreground flex items-center gap-3 text-sm">
            Saved
            <SignaturePadPreview strokes={saved} className="h-8 w-auto" />
          </div>
        )}
      </div>
    </div>
  )
}

Installation

pnpm dlx shadcn@latest add @reui/signature-pad

Usage

import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadControls,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  SignaturePadUndo,
} from "@/components/reui/signature-pad"
<SignaturePad onValueChange={setStrokes}>
  <SignaturePadArea>
    <SignaturePadGuide />
    <SignaturePadPlaceholder />
    <SignaturePadControls>
      <SignaturePadUndo />
      <SignaturePadClear />
    </SignaturePadControls>
  </SignaturePadArea>
</SignaturePad>

Examples

Undo and redo toolbar

import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  SignaturePadRedo,
  SignaturePadUndo,
} from "@/components/reui/signature-pad"

import { ButtonGroup } from "@/components/ui/button-group"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { BrushCleaningIcon } from 'lucide-react'

export function Pattern() {
  return (
    <SignaturePad className="mx-auto max-w-lg">
      <div className="flex items-center gap-3">
        <ButtonGroup aria-label="History">
          <SignaturePadUndo />
          <SignaturePadRedo />
        </ButtonGroup>
        <p className="text-muted-foreground hidden items-center gap-1.5 text-xs sm:flex">
          <KbdGroup>
            <Kbd>Ctrl</Kbd>
            <Kbd>Z</Kbd>
          </KbdGroup>
          to undo
        </p>
        <SignaturePadClear size="sm" className="ms-auto">
          <BrushCleaningIcon  aria-hidden="true" />
          Clear
        </SignaturePadClear>
      </div>
      <SignaturePadArea className="h-48">
        <SignaturePadGuide />
        <SignaturePadPlaceholder>Draw your signature</SignaturePadPlaceholder>
      </SignaturePadArea>
    </SignaturePad>
  )
}

Form field with validation

"use client"

import { useId, useState } from "react"
import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadGuide,
} from "@/components/reui/signature-pad"

import { Button } from "@/components/ui/button"
import {
  Field,
  FieldDescription,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"

export function Pattern() {
  const id = useId()
  const [invalid, setInvalid] = useState(false)
  const [submitted, setSubmitted] = useState<{
    name: string
    size: number
  } | null>(null)

  return (
    <form
      noValidate
      className="mx-auto w-full max-w-md"
      onSubmit={(event) => {
        event.preventDefault()
        const data = new FormData(event.currentTarget)
        /* The pad writes an SVG data URL into the form under its `name`. */
        const signature = String(data.get("signature") ?? "")
        setInvalid(!signature)
        setSubmitted(
          signature
            ? {
                name: String(data.get("name") ?? "").trim() || "Unnamed signer",
                size: new Blob([signature]).size,
              }
            : null
        )
      }}
      onReset={() => {
        setInvalid(false)
        setSubmitted(null)
      }}
    >
      <FieldGroup>
        <Field>
          <FieldLabel htmlFor={`${id}-name`}>Full name</FieldLabel>
          <Input
            id={`${id}-name`}
            name="name"
            autoComplete="name"
            placeholder="Maya Chen"
          />
        </Field>
        <Field data-invalid={invalid || undefined}>
          <FieldLabel id={`${id}-label`}>Signature</FieldLabel>
          <SignaturePad
            name="signature"
            format="svg"
            required
            onStrokeStart={() => setInvalid(false)}
          >
            <SignaturePadArea
              aria-labelledby={`${id}-label`}
              aria-describedby={`${id}-hint`}
              aria-invalid={invalid || undefined}
            >
              <SignaturePadGuide />
            </SignaturePadArea>
          </SignaturePad>
          {invalid ? (
            <FieldError id={`${id}-hint`}>Sign above to continue.</FieldError>
          ) : (
            <FieldDescription id={`${id}-hint`}>
              Use a mouse, a finger or a stylus.
            </FieldDescription>
          )}
        </Field>
        <div className="flex items-center gap-2">
          <Button type="submit">Submit</Button>
          <Button type="reset" variant="outline">
            Reset
          </Button>
          <p
            role="status"
            className="text-muted-foreground ms-auto min-w-0 truncate text-sm"
          >
            {submitted &&
              `Signed by ${submitted.name}, ${(submitted.size / 1024).toFixed(1)} KB SVG`}
          </p>
        </div>
      </FieldGroup>
    </form>
  )
}

Signing workspace

"use client"

import { useEffect, useRef, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  SignaturePadRedo,
  SignaturePadUndo,
  type SignaturePadApi,
  type SignaturePadStroke,
} from "@/components/reui/signature-pad"
import { cn } from "cn"

import { Button } from "@/components/ui/button"
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Separator } from "@/components/ui/separator"
import { CircleCheckIcon, DownloadIcon, ImageIcon, PenToolIcon, PlayIcon } from 'lucide-react'

/* `undefined` follows the theme's text color, so it stays legible in dark mode. */
const INKS = [
  { label: "Default", color: undefined, swatch: "bg-foreground" },
  { label: "Blue", color: "#2563eb", swatch: "bg-blue-600" },
  { label: "Green", color: "#059669", swatch: "bg-emerald-600" },
]

const WEIGHTS = [
  { label: "Fine", min: 0.5, max: 1.8, sample: 1 },
  { label: "Medium", min: 0.8, max: 3.2, sample: 1.75 },
  { label: "Bold", min: 1.6, max: 5.5, sample: 2.75 },
]

function Swatch({ className }: { className: string }) {
  return (
    <span
      aria-hidden="true"
      className={cn(
        "ring-foreground/15 size-3.5 shrink-0 rounded-full ring-1 ring-inset",
        className
      )}
    />
  )
}

function Stroke({ width }: { width: number }) {
  return (
    <svg
      aria-hidden="true"
      viewBox="0 0 24 12"
      fill="none"
      stroke="currentColor"
      strokeLinecap="round"
      strokeWidth={width}
      className="w-5 shrink-0"
    >
      <path d="M2 8c3-5 6-5 9-1s7 4 11-3" />
    </svg>
  )
}

function download(href: string, name: string) {
  const link = document.createElement("a")
  link.href = href
  link.download = name
  link.click()
}

function DownloadMenuIcon({ format }: { format: "png" | "svg" }) {
  return format === "png" ? (
    <ImageIcon  aria-hidden="true" />
  ) : (
    <PenToolIcon  aria-hidden="true" />
  )
}

const DOWNLOADS = [
  { format: "png", label: "PNG image" },
  { format: "svg", label: "SVG vector" },
] as const

export function Pattern() {
  const [ink, setInk] = useState(INKS[0])
  const [weight, setWeight] = useState(WEIGHTS[1])
  const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
  /* While set, the pad shows this partial copy instead of the real value. */
  const [replay, setReplay] = useState<SignaturePadStroke[] | null>(null)
  const [signedAt, setSignedAt] = useState<string | null>(null)
  const frame = useRef(0)
  const padRef = useRef<SignaturePadApi>(null)

  useEffect(() => () => cancelAnimationFrame(frame.current), [])

  const playBack = () => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
    const total = strokes.reduce((sum, stroke) => sum + stroke.points.length, 0)
    const perFrame = Math.max(2, Math.ceil(total / 80))
    let shown = 0
    const step = () => {
      shown += perFrame
      let left = shown
      const partial: SignaturePadStroke[] = []
      for (const stroke of strokes) {
        if (left <= 0) break
        partial.push(
          left >= stroke.points.length
            ? stroke
            : { ...stroke, points: stroke.points.slice(0, left) }
        )
        left -= stroke.points.length
      }
      if (shown < total) {
        setReplay(partial)
        frame.current = requestAnimationFrame(step)
      } else {
        setReplay(null)
      }
    }
    frame.current = requestAnimationFrame(step)
  }

  const locked = signedAt !== null || replay !== null

  /* Through apiRef, so a menu item outside the pad's parts can export it.
     Paper-white PNG, so the file reads the same on any page. */
  const save = (format: "png" | "svg") => {
    const value = padRef.current?.serialize(
      format,
      format === "png" ? { background: "#ffffff" } : undefined
    )
    if (value) download(value, `signature.${format}`)
  }

  return (
    <Card className="mx-auto w-full max-w-lg">
      <CardHeader>
        <CardTitle>Statement of work</CardTitle>
        <CardDescription>
          <span className="flex items-center gap-2">
            Harbor & Pine Design
            <span
              aria-hidden="true"
              className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
            />
            SOW-2041
          </span>
        </CardDescription>
        <CardAction>
          {/* Up here, away from Sign statement, so a double click on it
              cannot land on Edit and reopen what was just signed. */}
          <div className="flex items-center gap-2">
            {signedAt && (
              <Button
                variant="ghost"
                size="xs"
                onClick={() => {
                  setSignedAt(null)
                  requestAnimationFrame(() => padRef.current?.focus())
                }}
              >
                Edit
              </Button>
            )}
            <Badge variant={signedAt ? "success-light" : "secondary"}>
              {signedAt ? "Signed" : "Draft"}
            </Badge>
          </div>
        </CardAction>
      </CardHeader>
      <CardContent>
        <SignaturePad
          apiRef={padRef}
          /* Replay swaps in a partial copy; readOnly keeps a stray stroke
             from landing in it and being committed as the real value. */
          value={replay ?? strokes}
          onValueChange={setStrokes}
          color={ink.color}
          minWidth={weight.min}
          maxWidth={weight.max}
          readOnly={locked}
        >
          <SignaturePadArea aria-label="Client signature" className="h-44">
            {/* Raised off the edge so the name below the line has room. */}
            <SignaturePadGuide className="bottom-11" />
            <SignaturePadPlaceholder />
            <p
              aria-hidden="true"
              className="text-muted-foreground pointer-events-none absolute start-6 bottom-4 text-xs"
            >
              Daniel Reyes, Client
            </p>
          </SignaturePadArea>
          {/* One ghost row: each trigger shows its current choice and names
              it in the label, so the menus need no visible caption. */}
          <div className="flex items-center gap-1 pt-1">
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Ink color, ${ink.label}`}
                    disabled={locked}
                  />
                }
              >
                <Swatch className={ink.swatch} />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="start">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Ink color</DropdownMenuLabel>
                  <DropdownMenuRadioGroup
                    value={ink.label}
                    onValueChange={(value) => {
                      const option = INKS.find((item) => item.label === value)
                      if (option) setInk(option)
                    }}
                  >
                    {INKS.map((option) => (
                      /* Base UI keeps a radio menu open by default. */
                      <DropdownMenuRadioItem
                        key={option.label}
                        value={option.label}
                        closeOnClick
                      >
                        <Swatch className={option.swatch} />
                        {option.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Line weight, ${weight.label}`}
                    disabled={locked}
                  />
                }
              >
                <Stroke width={weight.sample} />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="start">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Line weight</DropdownMenuLabel>
                  <DropdownMenuRadioGroup
                    value={weight.label}
                    onValueChange={(value) => {
                      const option = WEIGHTS.find(
                        (item) => item.label === value
                      )
                      if (option) setWeight(option)
                    }}
                  >
                    {WEIGHTS.map((option) => (
                      <DropdownMenuRadioItem
                        key={option.label}
                        value={option.label}
                        closeOnClick
                      >
                        <Stroke width={option.sample} />
                        {option.label}
                      </DropdownMenuRadioItem>
                    ))}
                  </DropdownMenuRadioGroup>
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
            <Separator orientation="vertical" className="mx-1 my-auto h-4" />
            <SignaturePadUndo variant="ghost" />
            <SignaturePadRedo variant="ghost" />
            <Button
              variant="ghost"
              size="icon-sm"
              aria-label="Replay signature"
              disabled={strokes.length === 0 || locked}
              onClick={playBack}
            >
              <PlayIcon  aria-hidden="true" />
            </Button>
            <SignaturePadClear variant="ghost" />
            <DropdownMenu>
              <DropdownMenuTrigger
                render={
                  <Button
                    variant="ghost"
                    size="icon-sm"
                    aria-label="Download signature"
                    className="ms-auto"
                    disabled={strokes.length === 0 || replay !== null}
                  />
                }
              >
                <DownloadIcon  aria-hidden="true" />
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <DropdownMenuGroup>
                  <DropdownMenuLabel>Download as</DropdownMenuLabel>
                  {DOWNLOADS.map((option) => (
                    <DropdownMenuItem
                      key={option.format}
                      onClick={() => save(option.format)}
                    >
                      <DownloadMenuIcon format={option.format} />
                      {option.label}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuGroup>
              </DropdownMenuContent>
            </DropdownMenu>
          </div>
        </SignaturePad>
      </CardContent>
      <CardFooter>
        {signedAt ? (
          <p
            ref={(node) => node?.focus()}
            tabIndex={-1}
            role="status"
            className="text-muted-foreground ms-auto flex items-center gap-1.5 text-sm outline-none"
          >
            <CircleCheckIcon  aria-hidden="true" className="text-success size-4" />
            Signed {signedAt}
          </p>
        ) : (
          <Button
            className="ms-auto"
            disabled={strokes.length === 0 || replay !== null}
            onClick={() =>
              setSignedAt(
                new Intl.DateTimeFormat("en-US", {
                  dateStyle: "medium",
                  timeStyle: "short",
                }).format(new Date())
              )
            }
          >
            Sign statement
          </Button>
        )}
      </CardFooter>
    </Card>
  )
}

Export PNG, JPEG, SVG or JSON

"use client"

import { useState } from "react"
import { Frame, FramePanel } from "@/components/reui/frame"
import {
  getSignaturePadBounds,
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  SignaturePadSave,
  type SignaturePadFormat,
} from "@/components/reui/signature-pad"

import { buttonVariants } from "@/components/ui/button"
import {
  ButtonGroup,
  ButtonGroupText,
} from "@/components/ui/button-group"

const FORMATS: { format: SignaturePadFormat; label: string; ext: string }[] = [
  { format: "png", label: "PNG", ext: "png" },
  { format: "jpeg", label: "JPEG", ext: "jpg" },
  { format: "svg", label: "SVG", ext: "svg" },
  { format: "json", label: "JSON", ext: "json" },
]

type Result = (typeof FORMATS)[number] & {
  value: string
  width: number
  height: number
}

const PADDING = 16

export function Pattern() {
  const [result, setResult] = useState<Result | null>(null)
  const isImage = result !== null && result.format !== "json"

  return (
    <div className="mx-auto flex w-full max-w-lg flex-col gap-4">
      <SignaturePad onValueChange={() => setResult(null)}>
        <SignaturePadArea className="h-44">
          <SignaturePadGuide />
          <SignaturePadPlaceholder />
        </SignaturePadArea>
        <div className="flex flex-wrap items-center gap-2">
          <ButtonGroup aria-label="Export format">
            <ButtonGroupText>Export</ButtonGroupText>
            {FORMATS.map((item) => (
              <SignaturePadSave
                key={item.format}
                format={item.format}
                /* Paper-white, so the export reads the same in a dark UI. */
                options={{ background: "#ffffff", padding: PADDING }}
                size="sm"
                onSave={(value, strokes) => {
                  const bounds = getSignaturePadBounds(strokes)
                  setResult({
                    ...item,
                    value,
                    width: Math.ceil((bounds?.width ?? 0) + PADDING * 2),
                    height: Math.ceil((bounds?.height ?? 0) + PADDING * 2),
                  })
                }}
              >
                {item.label}
              </SignaturePadSave>
            ))}
          </ButtonGroup>
          <SignaturePadClear variant="ghost" size="sm" className="ms-auto">
            Clear
          </SignaturePadClear>
        </div>
      </SignaturePad>
      <p role="status" className="sr-only">
        {result &&
          `${result.label} ready, ${(new Blob([result.value]).size / 1024).toFixed(1)} KB`}
      </p>
      {result && (
        <Frame spacing="sm">
          <FramePanel>
            <div className="flex flex-col gap-3">
              <div className="flex items-center gap-2 text-sm">
                <span className="font-medium">{result.label}</span>
                <span
                  aria-hidden="true"
                  className="bg-muted-foreground/40 size-1 shrink-0 rounded-full"
                />
                <span className="text-muted-foreground">
                  {(new Blob([result.value]).size / 1024).toFixed(1)} KB
                </span>
                <a
                  href={
                    isImage
                      ? result.value
                      : `data:application/json;charset=utf-8,${encodeURIComponent(result.value)}`
                  }
                  download={`signature.${result.ext}`}
                  className={buttonVariants({
                    variant: "outline",
                    size: "xs",
                    className: "ms-auto",
                  })}
                >
                  Download
                </a>
              </div>
              {isImage ? (
                <img
                  src={result.value}
                  alt={`Signature exported as ${result.label}`}
                  width={result.width}
                  height={result.height}
                  className="max-h-32 w-auto self-start"
                />
              ) : (
                <p className="text-muted-foreground line-clamp-3 font-mono text-xs break-all">
                  {result.value}
                </p>
              )}
            </div>
          </FramePanel>
        </Frame>
      )}
    </div>
  )
}

Draw or type

"use client"

import { useId, useState } from "react"
import { Badge } from "@/components/reui/badge"
import {
  SignaturePad,
  SignaturePadArea,
  SignaturePadClear,
  SignaturePadControls,
  SignaturePadGuide,
  SignaturePadPlaceholder,
  type SignaturePadStroke,
} from "@/components/reui/signature-pad"

import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
  Tabs,
  TabsContent,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs"

export function Pattern() {
  const id = useId()
  const [mode, setMode] = useState<"draw" | "type">("draw")
  const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
  const [typed, setTyped] = useState("")
  const [adopted, setAdopted] = useState<"draw" | "type" | null>(null)

  /* Typing is the keyboard and screen reader route to the same outcome. */
  const ready = mode === "draw" ? strokes.length > 0 : typed.trim().length > 1

  return (
    <div className="mx-auto flex w-full max-w-md flex-col gap-4">
      <Tabs
        value={mode}
        onValueChange={(value) => setMode(value === "type" ? "type" : "draw")}
      >
        <TabsList>
          <TabsTrigger value="draw">Draw</TabsTrigger>
          <TabsTrigger value="type">Type</TabsTrigger>
        </TabsList>
        <TabsContent value="draw">
          <SignaturePad
            value={strokes}
            onValueChange={(next) => {
              setStrokes(next)
              setAdopted(null)
            }}
          >
            <SignaturePadArea className="h-40">
              <SignaturePadGuide />
              <SignaturePadPlaceholder />
              <SignaturePadControls position="top-end">
                <SignaturePadClear variant="ghost" />
              </SignaturePadControls>
            </SignaturePadArea>
          </SignaturePad>
        </TabsContent>
        <TabsContent value="type">
          <div className="flex flex-col gap-3">
            <Field>
              <FieldLabel htmlFor={`${id}-typed`}>Full name</FieldLabel>
              <Input
                id={`${id}-typed`}
                value={typed}
                onChange={(event) => {
                  setTyped(event.target.value)
                  setAdopted(null)
                }}
                autoComplete="name"
                placeholder="Maya Chen"
              />
            </Field>
            {/* A visual echo of the input, so it stays out of the reading order. */}
            <p
              aria-hidden="true"
              className="flex h-20 items-center justify-center border-b border-dashed px-4"
            >
              {typed.trim() ? (
                <span className="truncate font-serif text-3xl italic">
                  {typed.trim()}
                </span>
              ) : (
                <span className="text-muted-foreground text-sm">
                  Your typed signature appears here
                </span>
              )}
            </p>
          </div>
        </TabsContent>
      </Tabs>
      <div className="flex items-center gap-3">
        <Button disabled={!ready} onClick={() => setAdopted(mode)}>
          Adopt and sign
        </Button>
        <div role="status">
          {adopted && (
            <Badge variant="success-light">
              {adopted === "draw" ? "Drawn" : "Typed"} signature adopted
            </Badge>
          )}
        </div>
      </div>
    </div>
  )
}

Agreement signing card

"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.

type SignaturePadStroke = {
  points: [x: number, y: number, size: number][]
  color?: string
}

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.

const [strokes, setStrokes] = useState<SignaturePadStroke[]>([])
 
<SignaturePad value={strokes} onValueChange={setStrokes}>
  <SignaturePadArea />
</SignaturePad>

SignaturePad

The root. Holds the strokes and the history, and renders a div.

PropTypeDefaultDescription
valueSignaturePadStroke[]-Controlled strokes. A different value from outside clears undo.
defaultValueSignaturePadStroke[][]Initial strokes when uncontrolled.
onValueChange(value: SignaturePadStroke[]) => void-Fires per finished stroke, clear, undo and redo.
onStrokeStart(details: { pointerType: SignaturePadPointerType }) => void-Fires when a stroke begins, with the pointer that drew it.
onStrokeEnd(stroke: SignaturePadStroke) => void-Fires with each finished stroke.
apiRefRef<SignaturePadApi>-The pad's API, for code outside the tree.
colorstring-Ink color, recorded on each stroke. Unset, ink follows the area's text color.
minWidthnumber0.8Thinnest ink, in CSS pixels. Equal to maxWidth for a constant line.
maxWidthnumber3.2Thickest ink, in CSS pixels.
smoothingnumber0.5From 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")[]allAccept only these pointers, e.g. ["pen"] to ignore a resting palm.
disabledbooleanfalseStops input, dims the pad and removes it from the tab order.
readOnlybooleanfalseStops input, keeps the ink at full strength and removes the pad from the tab order.
namestring-Submits the signature with a native form under this name.
formstring-Associates the field with a form elsewhere in the page.
requiredbooleanfalseFails 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.

PropTypeDefaultDescription
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.

PropTypeDefault
position"top-start" | "top-end" | "bottom-start" | "bottom-end""top-end"

SignaturePadClear, SignaturePadUndo, SignaturePadRedo

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.

PropTypeDefaultDescription
format"png" | "jpeg" | "svg" | "json""png"Output format.
optionsSignaturePadExportOptions-Crop, padding, color, background and scale.
onSave(value: string, strokes: SignaturePadStroke[]) => void-Receives the serialized signature.

SignaturePadGuide

A dashed signing line with a cross at its start. Pass children to replace the cross, and a class to move the line.

SignaturePadPlaceholder

Centered hint text, shown only while the pad is empty and nobody is drawing. Defaults to "Sign here".

SignaturePadPreview

Saved strokes as an inline SVG cropped to the ink, for lists, receipts and documents. Works without a SignaturePad and accepts every svg prop.

PropTypeDefaultDescription
strokesSignaturePadStroke[]-Required. Renders nothing when empty.
paddingnumber4Space around the ink.
colorstring-Ink for strokes without their own color.

useSignaturePad

Returns the pad's API inside a SignaturePad. apiRef exposes the same members on one handle for the pad's life.

FieldType
strokesSignaturePadStroke[]
isEmpty, isDrawingboolean
canUndo, canRedoboolean
disabled, readOnlyboolean
clear, undo, redo, focus() => void
toSVG(options?: SignaturePadExportOptions) => string
toDataURL(options?: SignaturePadExportOptions) => string
toBlob(options?: SignaturePadExportOptions) => Promise<Blob | null>
serialize(format?, options?) => string

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.

OptionTypeDefaultDescription
paddingnumber8Space around the ink when cropping.
cropbooleantruefalse keeps the area's origin.
widthnumber-With height, a fixed frame from the origin.
heightnumber-With width, a fixed frame from the origin.
colorstring"#000000"Ink for strokes without their own color.
backgroundstringtransparentFill behind the ink. JPEG defaults to white.
scalenumber2Raster only. Pixels per CSS pixel.
type"image/png" | "image/jpeg" | "image/webp""image/png"Raster only.
qualitynumber-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.

HelperReturns
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.

<form onSubmit={handleSubmit}>
  <SignaturePad name="signature" required>
    <SignaturePadArea aria-labelledby="signature-label" />
  </SignaturePad>
</form>

Data Attributes

AttributeOnValues
data-emptyroot, areapresent while there is no ink
data-drawingroot, areapresent while a stroke is in progress
data-disabledroot, areapresent when disabled
data-readonlyroot, areapresent when read-only
data-variantareadefault, muted, ghost
data-positioncontrolstop-start, top-end, bottom-start, bottom-end
data-slotevery part, and each committed stroke in the areasignature-pad-*

Keyboard

KeyAction
TabMoves focus to the area.
Ctrl + ZUndoes the last edit.
Ctrl + Shift + ZRedoes.
Ctrl + YRedoes.

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.

Browse all 12 Shadcn Signature Pad components for copy-ready layouts, dashboards, and forms built with Tailwind CSS in the ReUI library.

ScrollspySortable

On This Page

InstallationUsageExamplesUndo and redo toolbarForm field with validationSigning workspaceExport PNG, JPEG, SVG or JSONDraw or typeAgreement signing cardSignature in a dialogStylus mode with palm rejectionInitials per clauseProof of deliveryUpload with apiRefAPI ReferenceValueSignaturePadSignaturePadAreaSignaturePadControlsSignaturePadClear, SignaturePadUndo, SignaturePadRedoSignaturePadSaveSignaturePadGuideSignaturePadPlaceholderSignaturePadPreviewuseSignaturePadSignaturePadExportOptionsHelpersFormsData AttributesKeyboardAccessibility

Application

  • App Shell
  • Auth
  • Card
  • ChartAdded 17 Chart blocks for cron jobs, deploys, firewall traffic and SLOs
  • Dashboard
  • Dialog
  • Empty State
  • Event Calendar
  • Flow
  • Form
  • Gantt
  • Kanban Board
  • List
  • Navbar
  • Onboarding
  • Profile
  • Rich Text EditorNew Rich Text Editor category with 5 Tiptap editor blocks
  • Schedule
  • Settings
  • SheetAdded 13 Sheet blocks for cron jobs, deploys, firewall rules, SLOs and data
  • Stats
  • Timeline
  • WhiteboardNew Whiteboard category with 2 Excalidraw board blocks
  • Wizard

Solutions

  • Dev OpsNew Dev Ops category with 6 platform console blocks
  • AI Ops
  • CRMAdded 2 CRM blocks: a forecast call workspace and a deal desk
  • Agents
  • Analytics
  • Billing
  • Bookings
  • Files
  • Inventory
  • Users

AI & Agents

  • AI ChatAdded a corner chat popup block
  • Agent Activity

Templates

  • E-commerce
  • SaaS
  • Dashboard
  • Landing
  • All templates

eCommerce

  • Category Card
  • Checkout
  • Comparison
  • Coupon
  • Filter Sidebar
  • Product Card
  • Product Detail
  • Product Grid
  • Receipt
  • Review
  • Shopping Cart
  • Wishlist
  • Shop Hero

Data Grid

  • Base
  • Columns
  • Drag & Drop
  • EditingAdded a spreadsheet reorder plan Data Grid block
  • Expansion
  • Filtering
  • Grouping
  • Virtualization

Marketing

  • Blog
  • Compare
  • Contact
  • CTA
  • FAQ
  • Hero
  • How It Works
  • PricingAdded 7 Pricing blocks: plan cards, comparison tables and upgrade dialogs

Resources

  • Components
  • Blocks
  • Icons
  • MCP for Agents
  • Docs
  • Support
  • Pricing
  • Tailwind Plus Alternative
  • Roadmap(has updates coming soon)
  • AffiliateSoon

Legal

  • Privacy Policy
  • Terms & Conditions
  • License
  • Refunds
  • Cookies

© 2026 ReUI. All rights reserved.

3.6K