Menu

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:

terminal
npx -y vui-registry-cli-v1@latest add velocity-signature-pad

Source Code

velocity-signature-pad.tsx
'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: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
onSave(dataUrl: string) => voidundefinedCallback fired with a PNG data URL when the user confirms the signature.
widthnumber600Canvas width in CSS pixels.
heightnumber300Canvas height in CSS pixels.
classNamestringundefinedOptional class name for the wrapper element.
Context Worth Keeping In Orbit

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.