UIPackage
Menu

Signature Pad

signature-pad ui
Edit on GitHub

Canvas-based digital signature capture with pointer events (mouse + touch). The value is a PNG data URL, with pen color/thickness, background, clear method, disabled/readonly states, and a live point count.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/signature-pad.json
Named registry: npx shadcn@latest add @uipkge-react/signature-pad Installs to: components/ui/signature-pad/

Examples

Props

Name Type / Values Default Required
modelValue

React equivalent of the Vue `v-model` binding — pair with `onModelChange`.

string | null optional
width number optional
height number optional
penColor string optional
penThickness number optional
backgroundColor string optional
exportFormat string optional
disabled boolean optional
readonly boolean optional
showClearButton boolean optional
clearLabel string optional
onModelChange

Fired with the new data URL (or null) whenever the signature changes.

(value: string | null) => void optional
onBegin

Fired when a stroke begins.

() => void optional
onEnd

Fired when a stroke ends.

() => void optional
onChange

Fired with the new data URL (or null) on every change (same payload as onModelChange).

(value: string | null) => void optional
actions

Render-prop for custom action controls. Mirrors the Vue `actions` slot.

(state: { clear: () => void; exportSignature: () => void; empty: boolean }) => React.ReactNode optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

SignaturePadRef
interface SignaturePadRef {
  clear: () => void
  exportSignature: () => void
  toDataURL: () => void
  pointCount: number
  isEmpty: boolean
}

Files installed (3)

  • components/ui/signature-pad/SignaturePad.tsx 7.8 kB
    import * as React from 'react'
    import { Eraser } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { signaturePadVariants } from './signature-pad.variants'
    
    export interface SignaturePadProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'onChange'> {
      /** PNG data URL of the current canvas contents, or null when empty. The
       *  React equivalent of the Vue `v-model` binding — pair with `onModelChange`. */
      modelValue?: string | null
      width?: number
      height?: number
      penColor?: string
      penThickness?: number
      backgroundColor?: string
      exportFormat?: string
      disabled?: boolean
      readonly?: boolean
      showClearButton?: boolean
      clearLabel?: string
      /** Fired with the new data URL (or null) whenever the signature changes. */
      onModelChange?: (value: string | null) => void
      /** Fired when a stroke begins. */
      onBegin?: () => void
      /** Fired when a stroke ends. */
      onEnd?: () => void
      /** Fired with the new data URL (or null) on every change (same payload as onModelChange). */
      onChange?: (value: string | null) => void
      /** Render-prop for custom action controls. Mirrors the Vue `actions` slot. */
      actions?: (state: { clear: () => void; exportSignature: () => void; empty: boolean }) => React.ReactNode
    }
    
    export interface SignaturePadRef {
      clear: () => void
      exportSignature: () => void
      toDataURL: () => void
      pointCount: number
      isEmpty: boolean
    }
    
    const SignaturePad = React.forwardRef<SignaturePadRef, SignaturePadProps>(
      (
        {
          modelValue,
          width = 400,
          height = 200,
          penColor = '#0a0a0a',
          penThickness = 2,
          backgroundColor = '#ffffff',
          exportFormat = 'image/png',
          disabled = false,
          readonly = false,
          showClearButton = true,
          clearLabel = 'Clear',
          className,
          onModelChange,
          onBegin,
          onEnd,
          onChange,
          actions,
          ...props
        },
        ref,
      ) => {
        const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
        const ctxRef = React.useRef<CanvasRenderingContext2D | null>(null)
        const isDrawingRef = React.useRef(false)
        const lastPosRef = React.useRef({ x: 0, y: 0 })
    
        const [pointCount, setPointCount] = React.useState(0)
        const [hasInk, setHasInk] = React.useState(false)
    
        // Keep the latest callbacks without re-running the canvas setup effect.
        const callbacksRef = React.useRef({ onModelChange, onBegin, onEnd, onChange })
        callbacksRef.current = { onModelChange, onBegin, onEnd, onChange }
    
        const isInteractive = !disabled && !readonly
    
        const setupCanvas = React.useCallback(() => {
          const canvas = canvasRef.current
          if (!canvas) return
          const dpr = window.devicePixelRatio || 1
          canvas.width = width * dpr
          canvas.height = height * dpr
          canvas.style.width = `${width}px`
          canvas.style.height = `${height}px`
          const context = canvas.getContext('2d')
          if (!context) return
          context.scale(dpr, dpr)
          context.lineCap = 'round'
          context.lineJoin = 'round'
          context.strokeStyle = penColor
          context.lineWidth = penThickness
          context.fillStyle = backgroundColor
          context.fillRect(0, 0, width, height)
          ctxRef.current = context
          setPointCount(0)
          setHasInk(false)
        }, [width, height, penColor, penThickness, backgroundColor])
    
        const getPointerPos = React.useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
          const canvas = canvasRef.current
          if (!canvas) return { x: 0, y: 0 }
          const rect = canvas.getBoundingClientRect()
          return { x: e.clientX - rect.left, y: e.clientY - rect.top }
        }, [])
    
        const exportSignature = React.useCallback(() => {
          const canvas = canvasRef.current
          if (!canvas) return
          const { onModelChange, onChange } = callbacksRef.current
          if (!hasInk) {
            onModelChange?.(null)
            onChange?.(null)
            return
          }
          const dataUrl = canvas.toDataURL(exportFormat)
          onModelChange?.(dataUrl)
          onChange?.(dataUrl)
        }, [exportFormat, hasInk])
    
        const clear = React.useCallback(() => {
          const canvas = canvasRef.current
          const context = ctxRef.current
          if (!canvas || !context) return
          context.fillStyle = backgroundColor
          context.fillRect(0, 0, width, height)
          setPointCount(0)
          setHasInk(false)
          callbacksRef.current.onModelChange?.(null)
          callbacksRef.current.onChange?.(null)
        }, [backgroundColor, width, height])
    
        React.useImperativeHandle(
          ref,
          (): SignaturePadRef => ({
            clear,
            exportSignature,
            toDataURL: exportSignature,
            pointCount,
            isEmpty: !hasInk,
          }),
          [clear, exportSignature, pointCount, hasInk],
        )
    
        React.useEffect(() => {
          setupCanvas()
          return () => {
            ctxRef.current = null
          }
        }, [setupCanvas])
    
        // modelValue is read-only from the outside; we do not paint it back onto
        // the canvas (mirrors the Vue component, which also never restores ink).
    
        const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
          if (!isInteractive || !ctxRef.current) return
          e.preventDefault()
          isDrawingRef.current = true
          const { x, y } = getPointerPos(e)
          lastPosRef.current = { x, y }
          ctxRef.current.beginPath()
          ctxRef.current.moveTo(x, y)
          setPointCount((c) => c + 1)
          setHasInk(true)
          callbacksRef.current.onBegin?.()
          canvasRef.current?.setPointerCapture(e.pointerId)
        }
    
        const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
          if (!isDrawingRef.current || !ctxRef.current) return
          e.preventDefault()
          const { x, y } = getPointerPos(e)
          const { x: lastX, y: lastY } = lastPosRef.current
          ctxRef.current.beginPath()
          ctxRef.current.moveTo(lastX, lastY)
          ctxRef.current.lineTo(x, y)
          ctxRef.current.stroke()
          lastPosRef.current = { x, y }
          setPointCount((c) => c + 1)
        }
    
        const handlePointerUp = (e: React.PointerEvent<HTMLCanvasElement>) => {
          if (!isDrawingRef.current) return
          isDrawingRef.current = false
          ctxRef.current?.closePath()
          canvasRef.current?.releasePointerCapture(e.pointerId)
          exportSignature()
          callbacksRef.current.onEnd?.()
        }
    
        return (
          <div
            data-uipkge=""
            data-slot="signature-pad"
            data-disabled={disabled ? '' : undefined}
            data-readonly={readonly ? '' : undefined}
            className={cn(signaturePadVariants(), className)}
            {...props}
          >
            <canvas
              ref={canvasRef}
              className={cn('block touch-none rounded-md', !isInteractive && 'pointer-events-none')}
              style={{ touchAction: 'none' }}
              aria-label={`Signature pad${disabled ? ' (disabled)' : ''}`}
              role="img"
              onPointerDown={handlePointerDown}
              onPointerMove={handlePointerMove}
              onPointerUp={handlePointerUp}
              onPointerCancel={handlePointerUp}
              onPointerLeave={handlePointerUp}
            />
            {showClearButton && isInteractive ? (
              <div className="flex items-center justify-between gap-2 pt-2">
                <span className="text-muted-foreground text-xs tabular-nums">{pointCount} points</span>
                <button
                  type="button"
                  className="text-muted-foreground hover:text-foreground focus-visible:ring-ring inline-flex items-center gap-1 rounded-md text-xs transition-colors focus-visible:ring-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50"
                  disabled={!hasInk}
                  onClick={clear}
                >
                  <Eraser className="size-3.5" />
                  {clearLabel}
                </button>
              </div>
            ) : null}
            {actions ? actions({ clear, exportSignature, empty: !hasInk }) : null}
          </div>
        )
      },
    )
    SignaturePad.displayName = 'SignaturePad'
    
    export { SignaturePad }
  • components/ui/signature-pad/signature-pad.variants.ts 0.3 kB
    import type { VariantProps } from 'class-variance-authority'
    import { cva } from 'class-variance-authority'
    
    export const signaturePadVariants = cva(
      'border-input bg-background inline-flex flex-col gap-1 rounded-lg border p-2 shadow-xs',
    )
    
    export type SignaturePadVariants = VariantProps<typeof signaturePadVariants>
  • components/ui/signature-pad/index.ts 0.2 kB
    export { SignaturePad, type SignaturePadProps, type SignaturePadRef } from './SignaturePad'
    export { signaturePadVariants, type SignaturePadVariants } from './signature-pad.variants'

Raw manifest: https://uipkge.dev/r/react/signature-pad.json