Signature Pad
signature-pad ui 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
$ pnpm dlx shadcn@latest add https://uipkge.dev/r/react/signature-pad.json $ npx shadcn@latest add https://uipkge.dev/r/react/signature-pad.json $ yarn dlx shadcn@latest add https://uipkge.dev/r/react/signature-pad.json $ bunx 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
} npm dependencies
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