Velocity Signature Pad
A production-ready HTML5 canvas signature pad with velocity-based stroke width — faster strokes render thinner, slower strokes render thicker, mimicking real pen pressure. Supports touch and mouse, high-DPI rendering, clear/confirm actions, verified overlay, and one-click PNG export.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add velocity-signature-padSource Code
'use client'
import React, { useRef, useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Eraser, Check, MousePointer2, ShieldCheck, PenLine, Download } from 'lucide-react'
import { cn } from '@/lib/utils'
interface VelocitySignaturePadProps extends React.HTMLAttributes<HTMLDivElement> {
onSave?: (dataUrl: string) => void
width?: number
height?: number
strokeColor?: string
backgroundColor?: string
}
export function VelocitySignaturePad({
className,
onSave,
width = 600,
height = 300,
...props
}: VelocitySignaturePadProps) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [hasSignature, setHasSignature] = useState(false)
const [isSaved, setIsSaved] = useState(false)
const [points, setPoints] = useState<{ x: number; y: number; time: number }[]>([])
const [inkColor, setInkColor] = useState('#09090b')
const minWidth = 1.2
const maxWidth = 3.8
const initCanvas = useCallback(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const dpr = window.devicePixelRatio || 1
canvas.width = width * dpr
canvas.height = height * dpr
ctx.scale(dpr, dpr)
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.clearRect(0, 0, width, height)
}, [width, height])
useEffect(() => {
initCanvas()
}, [initCanvas])
useEffect(() => {
const updateInkColor = () => {
const isDark = document.documentElement.classList.contains('dark')
setInkColor(isDark ? '#ffffff' : '#09090b')
}
updateInkColor()
const observer = new MutationObserver(updateInkColor)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const getPoint = (e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
const canvas = canvasRef.current
if (!canvas) return { x: 0, y: 0, time: 0 }
const rect = canvas.getBoundingClientRect()
const clientX = 'touches' in e ? e.touches[0].clientX : (e as MouseEvent).clientX
const clientY = 'touches' in e ? e.touches[0].clientY : (e as MouseEvent).clientY
return { x: clientX - rect.left, y: clientY - rect.top, time: Date.now() }
}
const startDrawing = (e: React.MouseEvent | React.TouchEvent) => {
setIsDrawing(true)
setIsSaved(false)
setPoints([getPoint(e)])
}
const draw = (e: React.MouseEvent | React.TouchEvent) => {
if (!isDrawing || !canvasRef.current) return
const ctx = canvasRef.current.getContext('2d')
if (!ctx) return
const point = getPoint(e)
const newPoints = [...points, point]
setPoints(newPoints)
if (newPoints.length < 2) return
const lastPoint = newPoints[newPoints.length - 2]
const dist = Math.hypot(point.x - lastPoint.x, point.y - lastPoint.y)
const time = point.time - lastPoint.time
const velocity = time > 0 ? dist / time : 0
const newWidth = Math.max(minWidth, Math.min(maxWidth, maxWidth - velocity * 0.5))
ctx.beginPath()
ctx.moveTo(lastPoint.x, lastPoint.y)
ctx.lineTo(point.x, point.y)
ctx.strokeStyle = inkColor
ctx.lineWidth = newWidth
ctx.stroke()
ctx.closePath()
if (!hasSignature) setHasSignature(true)
}
const stopDrawing = () => {
setIsDrawing(false)
setPoints([])
}
const clearSignature = () => {
initCanvas()
setHasSignature(false)
setIsSaved(false)
}
const handleSave = () => {
if (!canvasRef.current || !hasSignature) return
onSave?.(canvasRef.current.toDataURL('image/png'))
setIsSaved(true)
}
const handleDownload = () => {
if (!canvasRef.current || !hasSignature) return
const link = document.createElement('a')
link.download = 'signature.png'
link.href = canvasRef.current.toDataURL('image/png')
link.click()
}
return (
<div className={cn('flex flex-col gap-3 w-full group/container', className)} {...props}>
<div className="relative overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_8px_30px_rgb(0,0,0,0.04)] dark:border-zinc-800 dark:bg-zinc-950 transition-all duration-500 hover:shadow-[0_20px_50px_rgba(0,0,0,0.1)]">
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{ backgroundImage: 'radial-gradient(#000 0.5px, transparent 0.5px)', backgroundSize: '24px 24px' }}
/>
<canvas
ref={canvasRef}
onMouseDown={startDrawing}
onMouseMove={draw}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
onTouchStart={startDrawing}
onTouchMove={draw}
onTouchEnd={stopDrawing}
className="relative z-10 cursor-crosshair touch-none"
/>
<AnimatePresence>
{!hasSignature && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.1 }}
className="absolute inset-0 z-0 flex flex-col items-center justify-center pointer-events-none"
>
<MousePointer2 className="w-8 h-8 text-zinc-200 dark:text-zinc-800 mb-2 animate-bounce" />
<span className="text-zinc-300 dark:text-zinc-700 text-xl font-medium tracking-tight uppercase">
Draw Signature
</span>
</motion.div>
)}
</AnimatePresence>
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-20">
<AnimatePresence>
{hasSignature && !isSaved && (
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 20, opacity: 0 }}
className="flex items-center gap-1.5 p-1.5 rounded-full bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border border-zinc-200/50 dark:border-zinc-800/50 shadow-2xl"
>
<ActionButton onClick={clearSignature} icon={<Eraser className="w-4 h-4" />} label="Clear" variant="danger" />
<div className="w-px h-4 bg-zinc-200 dark:bg-zinc-800 mx-1" />
<ActionButton onClick={handleSave} icon={<Check className="w-4 h-4" />} label="Confirm" variant="primary" />
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{isSaved && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="absolute inset-0 z-30 flex items-center justify-center bg-white/60 dark:bg-zinc-950/60 backdrop-blur-[2px]"
>
<motion.div initial={{ scale: 0.5 }} animate={{ scale: 1 }} className="flex flex-col items-center">
<div className="p-4 rounded-full bg-zinc-900 text-white shadow-xl mb-3">
<ShieldCheck className="w-8 h-8" />
</div>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">Verified</p>
<button
onClick={() => setIsSaved(false)}
className="mt-4 text-xs font-medium text-zinc-500 hover:text-zinc-800 underline underline-offset-4"
>
Edit Signature
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="flex justify-between items-center px-1 text-[10px] font-medium text-zinc-400">
<div className="flex items-center gap-1.5 uppercase tracking-widest">
<PenLine className="w-3 h-3" />
<span>Pressure-sensitive stroke</span>
</div>
<button
type="button"
onClick={handleDownload}
disabled={!hasSignature}
className="flex items-center gap-1.5 hover:text-zinc-600 dark:hover:text-zinc-300 disabled:opacity-40 transition-colors"
>
<Download className="w-3 h-3" />
Export PNG
</button>
</div>
</div>
)
}
function ActionButton({
onClick,
icon,
label,
variant,
}: {
onClick: () => void
icon: React.ReactNode
label: string
variant: 'danger' | 'primary'
}) {
return (
<motion.button
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
onClick={onClick}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-full text-xs font-semibold transition-colors shadow-sm',
variant === 'danger'
? 'bg-transparent text-zinc-600 hover:bg-red-50 hover:text-red-600 dark:text-zinc-400 dark:hover:bg-red-950/30'
: 'bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-white'
)}
>
{icon}
{label}
</motion.button>
)
}
export default function VelocitySignaturePadDemo() {
return (
<div className="flex items-center justify-center min-h-screen bg-[#fafafa] dark:bg-zinc-950 p-6">
<div className="w-full max-w-xl">
<div className="mb-10 text-center">
<h2 className="text-3xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">Authorize Transfer</h2>
<p className="text-zinc-500 mt-2 text-balance">
Provide your handwritten signature to validate this transaction. Stroke weight responds to drawing speed.
</p>
</div>
<VelocitySignaturePad />
</div>
</div>
)
}
Dependencies
framer-motion: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| onSave | (dataUrl: string) => void | undefined | Callback fired with a PNG data URL when the user confirms the signature. |
| width | number | 600 | Canvas width in CSS pixels. |
| height | number | 300 | Canvas height in CSS pixels. |
| className | string | undefined | Optional class name for the wrapper element. |
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.