Velocity UI
Loading…
Menu

Velocity Signature Pad

A digital signature canvas where the 'ink' stroke weight responds to drawing velocity, simulating a real pen.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 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, Download, MousePointer2, ShieldCheck, Sparkles } 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,
  strokeColor = '#09090b',
  backgroundColor = '#ffffff',
  ...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 minWidth = 1.2
  const maxWidth = 3.8

  // Initialize Canvas with High DPI and Grid
  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'
    
    // Clear background
    ctx.clearRect(0, 0, width, height)
  }, [width, height])

  useEffect(() => {
    initCanvas()
  }, [initCanvas])

  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)
    const point = getPoint(e)
    setPoints([point])
  }

  const [inkColor, setInkColor] = useState('#09090b')

  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 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.sqrt(Math.pow(point.x - lastPoint.x, 2) + Math.pow(point.y - lastPoint.y, 2))
    const time = point.time - lastPoint.time
    const velocity = time > 0 ? dist / time : 0
    
    // Premium ink physics: width reacts to speed
    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
    const dataUrl = canvasRef.current.toDataURL('image/png')
    setIsSaved(true)
    onSave?.(dataUrl)
  }

  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)]">
        
        {/* Subtle Background Grid Pattern */}
        <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"
        />
        
        {/* Animated Placeholder */}
        <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>

        {/* Premium Floating Action Bar */}
        <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>

        {/* Success Overlay */}
        <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>
      
      {/* Enhanced Footer Information */}
      <div className="flex justify-between items-center px-1">
        <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-widest text-zinc-400">
          <Sparkles className="w-3 h-3 text-amber-500" />
          <span>Velocity Ink Engine</span>
        </div>
        <div className="flex items-center gap-3">
             <span className="text-[10px] text-zinc-400 font-medium tracking-tighter"></span>
             <div className="h-1 w-1 rounded-full bg-green-500 animate-pulse" />
        </div>
      </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>
  )
}

// Demo Component remains similar but with cleaner styling
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">Please provide your handwritten signature to validate this transaction.</p>
        </div>
        
        <VelocitySignaturePad />
      </div>
    </div>
  )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
widthnumber-Width of the canvas in pixels
heightnumber-Height of the canvas in pixels
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.