Signature Pad

Capture a drawn or typed signature and export it as SVG or a PNG data URL.

@drift-ui/signature-padRegistry JSON
Sign here

Sign to confirm delivery

On this page

Installation

The CLI also installs the npm packages and registry items it builds on. Register the namespace once first with https://drift-ui.dev/r/{style}/{name}.json. The CLI fills in {style} from your components.json and installs the Base UI or Radix build to match. See setup.

Usage

signature-pad-usage.tsx
"use client"

import { useState } from "react"
import { SignaturePad } from "@/components/ui/signature-pad"

export function DeliveryConfirmation() {
  const [signature, setSignature] = useState("")

  return <SignaturePad aria-label="Delivery signature" value={signature} onValueChange={setSignature} format="svg" penColor="currentColor" />
}

Examples

PNG export

format="png" emits a transparent PNG data URL at twice the pad size instead of SVG markup.

Sign here

Pen color

penColor accepts any CSS color, including theme variables.

Sign here

Saved signature

A value the pad did not draw is shown as an image. Clear removes it so the signer can start again.

Disabled

disabled dims the pad and ignores pointer input.

Sign here

API reference

SignaturePad

A drawing surface for mouse, pen and touch with undo and clear, plus a typed-name fallback for keyboard and screen reader users. Exports SVG markup or a PNG data URL. Pass aria-label.

Prop
valuestringControlled signature.
defaultValuestringDefault""Initial value when uncontrolled.
onValueChange(value: string) => voidCalled at the end of each stroke, or an empty string once nothing is signed.
format"svg" | "png"Default"svg"SVG markup, or a transparent PNG data URL.
penColorstringDefault"currentColor"Any CSS color. currentColor stays in exported SVG so it inherits; other colors are resolved at export.
namestringSubmits the value through a hidden input.
placeholderstringDefault"Sign here"Hint shown on the empty pad.
disabledbooleanDims the pad and ignores pointer input.

Other props go to the <div>.

Source

components/ui/signature-pad.tsx
"use client"

import * as React from "react"
import { EraserIcon, KeyboardIcon, PenLineIcon, Undo2Icon } from "lucide-react"

import { useControllableState } from "@/hooks/use-controllable-state"
import {
  exportSignature,
  nameFontSize,
  PAD_HEIGHT,
  PAD_WIDTH,
  PEN_WIDTH,
  SIGNATURE_BASELINE,
  signatureImageSrc,
  strokeToPath,
  type SignatureFormat,
  type SignatureInk,
  type SignaturePoint,
  type SignatureStroke,
} from "@/lib/signature-pad"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"

function SignaturePad({
  value,
  defaultValue = "",
  onValueChange,
  format = "svg",
  penColor = "currentColor",
  name,
  placeholder = "Sign here",
  disabled = false,
  className,
  "aria-label": ariaLabel = "Signature",
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  format?: SignatureFormat
  penColor?: string
  name?: string
  placeholder?: string
  disabled?: boolean
}) {
  const { value: signature, setValue } = useControllableState({
    value,
    defaultValue,
    onChange: onValueChange,
  })
  const [strokes, setStrokes] = React.useState<SignatureStroke[]>([])
  const [typing, setTyping] = React.useState(false)
  const [typedName, setTypedName] = React.useState("")
  const [activePointer, setActivePointer] = React.useState<number | null>(null)
  const [exported, setExported] = React.useState(signature)
  const surfaceRef = React.useRef<SVGSVGElement>(null)
  const draftRef = React.useRef<SVGPathElement>(null)
  const draftStroke = React.useRef<SignatureStroke>([])

  if (signature !== exported) {
    setExported(signature)
    setStrokes([])
    setTypedName("")
    setTyping(false)
  }

  const trimmedName = typedName.trim()
  const hasInk = typing ? trimmedName.length > 0 : strokes.length > 0
  const saved = signature !== "" && !hasInk
  const showPlaceholder = !hasInk && !saved && activePointer === null

  function commit(ink: SignatureInk) {
    const color =
      format === "svg" && penColor === "currentColor"
        ? penColor
        : getComputedStyle(surfaceRef.current!).color
    const next = exportSignature(ink, format, color)
    setExported(next)
    setValue(next)
  }

  function toPoint(event: { clientX: number; clientY: number }) {
    const rect = surfaceRef.current!.getBoundingClientRect()
    return [
      ((event.clientX - rect.left) / rect.width) * PAD_WIDTH,
      ((event.clientY - rect.top) / rect.height) * PAD_HEIGHT,
    ] satisfies SignaturePoint
  }

  function handlePointerDown(event: React.PointerEvent<SVGSVGElement>) {
    if (typing || saved || disabled) return
    if (event.button !== 0 || activePointer !== null) return
    event.currentTarget.setPointerCapture(event.pointerId)
    draftStroke.current = [toPoint(event)]
    draftRef.current?.setAttribute("d", strokeToPath(draftStroke.current))
    setActivePointer(event.pointerId)
  }

  function handlePointerMove(event: React.PointerEvent<SVGSVGElement>) {
    if (event.pointerId !== activePointer) return
    const samples = event.nativeEvent.getCoalescedEvents?.() ?? []
    const moves = samples.length > 0 ? samples : [event]
    draftStroke.current.push(...moves.map(toPoint))
    draftRef.current?.setAttribute("d", strokeToPath(draftStroke.current))
  }

  function handlePointerEnd(event: React.PointerEvent<SVGSVGElement>) {
    if (event.pointerId !== activePointer) return
    const next = [...strokes, draftStroke.current]
    draftRef.current?.removeAttribute("d")
    setActivePointer(null)
    setStrokes(next)
    commit(next)
  }

  function undo() {
    const next = strokes.slice(0, -1)
    setStrokes(next)
    commit(next)
  }

  function clear() {
    setStrokes([])
    setTypedName("")
    commit([])
  }

  function toggleMode() {
    setTyping(!typing)
    commit(typing ? strokes : trimmedName)
  }

  const inertClassName =
    "aria-disabled:pointer-events-none aria-disabled:opacity-50"

  return (
    <div
      role="group"
      data-slot="signature-pad"
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      className={cn("grid w-full gap-2", className)}
      {...props}
    >
      <div
        data-slot="signature-pad-surface"
        className={cn(
          "relative overflow-hidden rounded-md border border-input bg-transparent shadow-xs dark:bg-input/30",
          disabled && "opacity-50"
        )}
      >
        <svg
          ref={surfaceRef}
          role="img"
          aria-label={
            hasInk || saved ? `${ariaLabel} added` : `${ariaLabel} empty`
          }
          viewBox={`0 0 ${PAD_WIDTH} ${PAD_HEIGHT}`}
          style={{ color: penColor }}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerEnd}
          onPointerCancel={handlePointerEnd}
          className={cn(
            "block aspect-3/1 w-full touch-none select-none",
            !typing && !saved && !disabled && "cursor-crosshair"
          )}
        >
          <line
            x1={24}
            x2={PAD_WIDTH - 24}
            y1={SIGNATURE_BASELINE + 8}
            y2={SIGNATURE_BASELINE + 8}
            className="stroke-border"
          />
          {typing ? (
            <text
              x={PAD_WIDTH / 2}
              y={SIGNATURE_BASELINE}
              textAnchor="middle"
              fontFamily="cursive"
              fontStyle="italic"
              fontSize={nameFontSize(trimmedName)}
              fill="currentColor"
            >
              {trimmedName}
            </text>
          ) : (
            <g
              fill="none"
              stroke="currentColor"
              strokeWidth={PEN_WIDTH}
              strokeLinecap="round"
              strokeLinejoin="round"
            >
              {strokes.map((stroke, index) => (
                <path key={index} d={strokeToPath(stroke)} />
              ))}
              <path ref={draftRef} />
            </g>
          )}
        </svg>
        {saved && (
          <div
            data-slot="signature-pad-saved"
            className="pointer-events-none absolute inset-0"
            style={{
              backgroundColor: penColor,
              mask: `url("${signatureImageSrc(signature)}") center / contain no-repeat`,
            }}
          />
        )}
        {showPlaceholder && (
          <span className="pointer-events-none absolute bottom-1/4 left-6 text-sm text-muted-foreground">
            {placeholder}
          </span>
        )}
      </div>
      {typing && (
        <Input
          autoFocus
          aria-label="Type your name"
          placeholder="Your full name"
          value={typedName}
          disabled={disabled}
          onChange={(event) => {
            setTypedName(event.target.value)
            commit(event.target.value.trim())
          }}
        />
      )}
      <div
        data-slot="signature-pad-actions"
        className="flex items-center gap-1"
      >
        <Button
          type="button"
          variant="ghost"
          size="sm"
          disabled={disabled || saved}
          onClick={toggleMode}
        >
          {typing ? <PenLineIcon /> : <KeyboardIcon />}
          {typing ? "Draw instead" : "Type instead"}
        </Button>
        {!typing && (
          <Button
            type="button"
            variant="ghost"
            size="sm"
            disabled={disabled}
            aria-disabled={strokes.length === 0 || undefined}
            onClick={() => strokes.length > 0 && undo()}
            className={cn("ml-auto", inertClassName)}
          >
            <Undo2Icon />
            Undo
          </Button>
        )}
        <Button
          type="button"
          variant="ghost"
          size="sm"
          disabled={disabled}
          aria-disabled={signature === "" || undefined}
          onClick={() => signature !== "" && clear()}
          className={cn(typing && "ml-auto", inertClassName)}
        >
          <EraserIcon />
          Clear
        </Button>
      </div>
      {name && (
        <input
          type="hidden"
          name={name}
          disabled={disabled}
          value={signature}
        />
      )}
    </div>
  )
}

export { SignaturePad }