Skip to content
6

Signature Pad

Canvas signature capture with velocity-based ink, per-stroke undo, theme-aware re-inking and PNG/JPEG export via ref.

Example

Installation

npx shadcn@latest add https://hirael.com/r/signature-pad.json

Usage

import { SignaturePad, SignaturePadClear, SignaturePadUndo } from "@/components/signature-pad"

Paths assume the default @/ alias from your components.json. The example above shows the parts composed.

API

<SignaturePad />

+ native element props
PropTypeDefault
penColorstringnone
minStrokeWidthnumber1.5
maxStrokeWidthnumber3.5
onChange((isEmpty: boolean) => void)none
onStrokeEnd(() => void)none
disabledbooleannone
placeholderReact.ReactNodenone
refReact.Ref<SignaturePadRef>none

<SignaturePadClear />

+ native element props
PropTypeDefault
size"sm" | "lg" | "default" | "icon" | "xs" | "icon-xs" | "icon-sm" | "icon-lg" | nullnone
variant"link" | "default" | "ghost" | "outline" | "destructive" | "secondary" | nullnone
asChildbooleannone

<SignaturePadUndo />

+ native element props
PropTypeDefault
size"sm" | "lg" | "default" | "icon" | "xs" | "icon-xs" | "icon-sm" | "icon-lg" | nullnone
variant"link" | "default" | "ghost" | "outline" | "destructive" | "secondary" | nullnone
asChildbooleannone

Component source

'use client';

import * as React from 'react';
import { Eraser, Undo2 } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Button } from '@/registry/hirael/bases/radix/ui/button';

interface Point {
  x: number;
  y: number;
  w: number;
}
type Stroke = Point[];

export interface SignaturePadRef {
  clear: () => void;
  undo: () => void;
  isEmpty: () => boolean;
  toDataURL: (type?: string, opts?: { backgroundColor?: string }) => string;
}

interface SignaturePadContextValue {
  clear: () => void;
  undo: () => void;
  empty: boolean;
  disabled?: boolean;
}

const SignaturePadContext = React.createContext<SignaturePadContextValue | null>(null);

const useSignaturePad = () => {
  const ctx = React.useContext(SignaturePadContext);
  if (!ctx) {
    throw new Error('SignaturePad compound components must be used inside <SignaturePad>');
  }
  return ctx;
};

const midpoint = (a: Point, b: Point) => {
  return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
};

const drawSegment = (ctx: CanvasRenderingContext2D, points: Stroke, i: number) => {
  const prev = points[i - 1];
  const curr = points[i];
  const start = i > 1 ? midpoint(points[i - 2], prev) : prev;
  const end = midpoint(prev, curr);
  ctx.beginPath();
  ctx.moveTo(start.x, start.y);
  ctx.quadraticCurveTo(prev.x, prev.y, end.x, end.y);
  ctx.lineWidth = curr.w;
  ctx.stroke();
};

const drawStroke = (ctx: CanvasRenderingContext2D, points: Stroke) => {
  if (points.length === 0) return;
  if (points.length === 1) {
    const p = points[0];
    ctx.beginPath();
    ctx.arc(p.x, p.y, Math.max(p.w / 2, 0.5), 0, Math.PI * 2);
    ctx.fill();
    return;
  }
  for (let i = 1; i < points.length; i++) {
    drawSegment(ctx, points, i);
  }
};

export interface SignaturePadProps extends Omit<React.ComponentProps<'div'>, 'onChange' | 'ref'> {
  penColor?: string;
  minStrokeWidth?: number;
  maxStrokeWidth?: number;
  onChange?: (isEmpty: boolean) => void;
  onStrokeEnd?: () => void;
  disabled?: boolean;
  placeholder?: React.ReactNode;
  ref?: React.Ref<SignaturePadRef>;
}

const SignaturePad = ({
  penColor,
  minStrokeWidth = 1.5,
  maxStrokeWidth = 3.5,
  onChange,
  onStrokeEnd,
  disabled,
  placeholder,
  className,
  children,
  ref,
  ...props
}: SignaturePadProps) => {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const strokesRef = React.useRef<Stroke[]>([]);
  const drawingRef = React.useRef(false);
  const activePointerRef = React.useRef<number | null>(null);
  const lastTimeRef = React.useRef(0);
  const [empty, setEmpty] = React.useState(true);
  const emptyRef = React.useRef(true);

  const onChangeRef = React.useRef(onChange);
  onChangeRef.current = onChange;
  const onStrokeEndRef = React.useRef(onStrokeEnd);
  onStrokeEndRef.current = onStrokeEnd;

  const setEmptyState = React.useCallback((next: boolean) => {
    if (emptyRef.current === next) return;
    emptyRef.current = next;
    setEmpty(next);
    onChangeRef.current?.(next);
  }, []);

  const resolveInk = React.useCallback(() => {
    if (penColor) return penColor;
    const canvas = canvasRef.current;
    if (!canvas) return '#000';
    return getComputedStyle(canvas).color || '#000';
  }, [penColor]);

  const getCtx = React.useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext('2d');
    if (!canvas || !ctx) return null;
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    const ink = resolveInk();
    ctx.strokeStyle = ink;
    ctx.fillStyle = ink;
    return ctx;
  }, [resolveInk]);

  const redraw = React.useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = getCtx();
    if (!canvas || !ctx) return;
    const dpr = window.devicePixelRatio || 1;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, canvas.width / dpr, canvas.height / dpr);
    for (const stroke of strokesRef.current) {
      drawStroke(ctx, stroke);
    }
  }, [getCtx]);

  React.useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    const resize = () => {
      const dpr = window.devicePixelRatio || 1;
      const rect = canvas.getBoundingClientRect();
      canvas.width = Math.max(1, Math.round(rect.width * dpr));
      canvas.height = Math.max(1, Math.round(rect.height * dpr));
      redraw();
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(canvas);
    const mo = new MutationObserver(redraw);
    mo.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ['class', 'style'],
    });
    return () => {
      ro.disconnect();
      mo.disconnect();
    };
  }, [redraw]);

  const clear = React.useCallback(() => {
    strokesRef.current = [];
    redraw();
    setEmptyState(true);
  }, [redraw, setEmptyState]);

  const undo = React.useCallback(() => {
    strokesRef.current = strokesRef.current.slice(0, -1);
    redraw();
    setEmptyState(strokesRef.current.length === 0);
  }, [redraw, setEmptyState]);

  React.useImperativeHandle(
    ref,
    () => ({
      clear,
      undo,
      isEmpty: () => strokesRef.current.length === 0,
      toDataURL: (type = 'image/png', opts) => {
        const canvas = canvasRef.current;
        if (!canvas) return '';
        const off = document.createElement('canvas');
        off.width = canvas.width;
        off.height = canvas.height;
        const ctx = off.getContext('2d');
        if (!ctx) return '';
        const dpr = window.devicePixelRatio || 1;
        if (opts?.backgroundColor) {
          ctx.fillStyle = opts.backgroundColor;
          ctx.fillRect(0, 0, off.width, off.height);
        }
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        const ink = resolveInk();
        ctx.strokeStyle = ink;
        ctx.fillStyle = ink;
        for (const stroke of strokesRef.current) {
          drawStroke(ctx, stroke);
        }
        return off.toDataURL(type);
      },
    }),
    [clear, undo, resolveInk],
  );

  const pointFromEvent = (e: React.PointerEvent<HTMLCanvasElement>) => {
    const rect = e.currentTarget.getBoundingClientRect();
    return { x: e.clientX - rect.left, y: e.clientY - rect.top };
  };

  const handlePointerDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (disabled || !e.isPrimary || drawingRef.current) return;
    e.preventDefault();
    e.currentTarget.setPointerCapture(e.pointerId);
    drawingRef.current = true;
    activePointerRef.current = e.pointerId;
    lastTimeRef.current = e.timeStamp;
    const { x, y } = pointFromEvent(e);
    const w = (minStrokeWidth + maxStrokeWidth) / 2;
    strokesRef.current = [...strokesRef.current, [{ x, y, w }]];
    const ctx = getCtx();
    if (ctx) {
      ctx.beginPath();
      ctx.arc(x, y, Math.max(w / 2, 0.5), 0, Math.PI * 2);
      ctx.fill();
    }
    setEmptyState(false);
  };

  const handlePointerMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!drawingRef.current || disabled || e.pointerId !== activePointerRef.current) {
      return;
    }
    const stroke = strokesRef.current[strokesRef.current.length - 1];
    if (!stroke) return;
    const prev = stroke[stroke.length - 1];
    const { x, y } = pointFromEvent(e);
    const dist = Math.hypot(x - prev.x, y - prev.y);
    if (dist < 0.5) return;
    const dt = Math.max(1, e.timeStamp - lastTimeRef.current);
    lastTimeRef.current = e.timeStamp;
    const velocity = dist / dt;
    const target = maxStrokeWidth - (maxStrokeWidth - minStrokeWidth) * Math.min(1, velocity / 3);
    const w = prev.w + (target - prev.w) * 0.35;
    stroke.push({ x, y, w });
    const ctx = getCtx();
    if (ctx) drawSegment(ctx, stroke, stroke.length - 1);
  };

  const endStroke = (e: React.PointerEvent<HTMLCanvasElement>) => {
    if (!drawingRef.current || e.pointerId !== activePointerRef.current) return;
    drawingRef.current = false;
    activePointerRef.current = null;
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
    onStrokeEndRef.current?.();
  };

  const ctxValue = React.useMemo<SignaturePadContextValue>(
    () => ({ clear, undo, empty, disabled }),
    [clear, undo, empty, disabled],
  );

  return (
    <SignaturePadContext.Provider value={ctxValue}>
      <div
        data-slot="signature-pad"
        data-empty={empty ? '' : undefined}
        data-disabled={disabled ? '' : undefined}
        className={cn(
          'relative h-40 w-full overflow-hidden rounded-md border border-input bg-transparent text-foreground transition-colors',
          'focus-within:border-ring',
          disabled && 'pointer-events-none opacity-50',
          className,
        )}
        {...props}
      >
        <canvas
          ref={canvasRef}
          data-slot="signature-pad-canvas"
          role="img"
          aria-label="Signature pad"
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={endStroke}
          onPointerCancel={endStroke}
          className="absolute inset-0 size-full cursor-crosshair touch-none"
        />
        <div
          aria-hidden
          data-slot="signature-pad-baseline"
          className={cn(
            'pointer-events-none absolute bottom-7 start-4 end-4 flex items-end gap-2 transition-opacity duration-300',
            empty ? 'opacity-100' : 'opacity-0',
          )}
        >
          <span className="pb-1 text-sm text-muted-foreground/70"></span>
          <span className="h-px flex-1 border-b border-dashed border-muted-foreground/40" />
        </div>
        {placeholder != null && (
          <div
            aria-hidden
            data-slot="signature-pad-placeholder"
            className={cn(
              'pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center text-sm text-muted-foreground/60 transition-opacity duration-300',
              empty ? 'opacity-100' : 'opacity-0',
            )}
          >
            {placeholder}
          </div>
        )}
        {children}
      </div>
    </SignaturePadContext.Provider>
  );
};

const SignaturePadClear = ({ className, children, ...props }: React.ComponentProps<typeof Button>) => {
  const ctx = useSignaturePad();
  return (
    <Button
      type="button"
      variant="outline"
      size="sm"
      data-slot="signature-pad-clear"
      disabled={ctx.disabled || ctx.empty}
      onClick={() => ctx.clear()}
      className={cn(className)}
      {...props}
    >
      <Eraser aria-hidden />
      {children ?? 'Clear'}
    </Button>
  );
};

const SignaturePadUndo = ({ className, children, ...props }: React.ComponentProps<typeof Button>) => {
  const ctx = useSignaturePad();
  return (
    <Button
      type="button"
      variant="outline"
      size="sm"
      data-slot="signature-pad-undo"
      disabled={ctx.disabled || ctx.empty}
      onClick={() => ctx.undo()}
      className={cn(className)}
      {...props}
    >
      <Undo2 aria-hidden />
      {children ?? 'Undo'}
    </Button>
  );
};

export { SignaturePad, SignaturePadClear, SignaturePadUndo };

Dependencies

shadcn registry

button

npm

lucide-react