Skip to contentVibraUI
Inputs & filters

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-pad

Needs the @vibra registry in your components.json — set it up once.

Examples

Typed

A signature typed on the keyboard, and drawn by SignatureView where it is shown.

Props

PropTypeDefaultDescription
valueSignatureValue | undefined—The signature, controlled whenever the key is present — value={undefined} is an empty pad you hold.
defaultValueSignatureValue—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.
namestring—Posts the signature with its form, as JSON in a hidden input of this name (empty when unsigned).
labelstring"Signature"What the pad is for — "Guest signature"; names the group.
heightnumber160The drawing surface's height in pixels; its width is the pad's.
disabledbooleanfalseNo drawing, typing or tab change.
classNamestring—Merged onto the root; the rest of the div props are spread there too.
SignatureViewReact.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

Source

components/ui/signature-pad/index.tsx
"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 }
components/ui/signature-pad/signature.ts
/**
 * 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}”`
}
components/ui/signature-pad/signature-view.tsx
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 }