EmpireUI
Get Pro
← Blog7 min read#react#canvas#signature-pad

Signature Pad in React: Canvas Drawing Component

Build a canvas-based signature pad in React from scratch — pointer events, smooth Bezier curves, export to PNG/SVG, and Tailwind v4 styling. No library required.

A hand signing a document on a tablet with a stylus, representing digital signature input

Why Build a Signature Pad From Scratch

Honestly, most signature pad libraries you'll find on npm are either abandoned, bloated, or fighting your existing setup. If you're already using React 18+ with Tailwind v4, dropping in a library that bundles its own event handling and canvas management is more trouble than it's worth.

The core of a signature pad is maybe 120 lines of TypeScript. You need a <canvas> element, pointer event listeners, Bezier curve smoothing so the lines don't look jagged, and an export function. That's it. You don't need a dependency for that.

We're going to build one that handles mouse, touch, and stylus input through the Pointer Events API — which gives you pressure sensitivity data for free on supported devices. It'll export to PNG or SVG, work inside forms, and integrate with react-hook-form without any hacks.

Setting Up the Canvas and Pointer Events

The Pointer Events API (pointerdown, pointermove, pointerup) replaced the old mouse/touch split. One set of handlers covers everything — mouse, touch, Apple Pencil, Surface pen. You also get pressure off PointerEvent.pressure, which ranges from 0 to 1 on stylus devices.

Here's the base component structure. We use useRef for the canvas element and a isDrawing ref (not state — we don't want a re-render on every pointer move event).

import { useRef, useEffect, useCallback } from 'react';

interface SignaturePadProps {
  width?: number;
  height?: number;
  strokeColor?: string;
  strokeWidth?: number;
  onEnd?: (dataUrl: string) => void;
  className?: string;
}

export function SignaturePad({
  width = 600,
  height = 200,
  strokeColor = '#1a1a1a',
  strokeWidth = 2,
  onEnd,
  className,
}: SignaturePadProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const isDrawing = useRef(false);
  const lastPoint = useRef<{ x: number; y: number } | null>(null);

  const getCtx = () => canvasRef.current?.getContext('2d') ?? null;

  const getCanvasPoint = (e: PointerEvent) => {
    const canvas = canvasRef.current!;
    const rect = canvas.getBoundingClientRect();
    const scaleX = canvas.width / rect.width;
    const scaleY = canvas.height / rect.height;
    return {
      x: (e.clientX - rect.left) * scaleX,
      y: (e.clientY - rect.top) * scaleY,
    };
  };

  const onPointerDown = useCallback((e: PointerEvent) => {
    e.preventDefault();
    isDrawing.current = true;
    lastPoint.current = getCanvasPoint(e);
    const ctx = getCtx();
    if (!ctx) return;
    ctx.beginPath();
    ctx.moveTo(lastPoint.current.x, lastPoint.current.y);
  }, []);

  const onPointerMove = useCallback((e: PointerEvent) => {
    if (!isDrawing.current) return;
    const ctx = getCtx();
    const pt = getCanvasPoint(e);
    if (!ctx || !lastPoint.current) return;

    // Quadratic Bezier for smoother curves
    const midX = (lastPoint.current.x + pt.x) / 2;
    const midY = (lastPoint.current.y + pt.y) / 2;
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth * (e.pressure > 0 ? e.pressure * 2 : 1);
    ctx.lineCap = 'round';
    ctx.lineJoin = 'round';
    ctx.quadraticCurveTo(lastPoint.current.x, lastPoint.current.y, midX, midY);
    ctx.stroke();
    lastPoint.current = pt;
  }, [strokeColor, strokeWidth]);

  const onPointerUp = useCallback(() => {
    isDrawing.current = false;
    lastPoint.current = null;
    onEnd?.(canvasRef.current?.toDataURL('image/png') ?? '');
  }, [onEnd]);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    canvas.addEventListener('pointerdown', onPointerDown);
    canvas.addEventListener('pointermove', onPointerMove);
    canvas.addEventListener('pointerup', onPointerUp);
    canvas.addEventListener('pointerleave', onPointerUp);
    return () => {
      canvas.removeEventListener('pointerdown', onPointerDown);
      canvas.removeEventListener('pointermove', onPointerMove);
      canvas.removeEventListener('pointerup', onPointerUp);
      canvas.removeEventListener('pointerleave', onPointerLeave);
    };
  }, [onPointerDown, onPointerMove, onPointerUp]);

  return (
    <canvas
      ref={canvasRef}
      width={width}
      height={height}
      className={className}
      style={{ touchAction: 'none' }}
    />
  );
}

The touchAction: 'none' inline style is non-negotiable. Without it, mobile browsers intercept scroll gestures and the drawing breaks. Tailwind doesn't have a clean utility for this in v4.0.2 yet, so inline it is.

Smooth Lines With Bezier Curve Interpolation

Raw lineTo calls produce angular, polygonal strokes. You can see it especially on slow, deliberate movements — the signature looks like it was drawn with a ruler at weird angles. Quadratic Bezier curves fix this by drawing through the midpoint between the current and last position.

The trick from the code above is quadraticCurveTo(lastPoint.x, lastPoint.y, midX, midY). The last recorded point becomes the control point, and the midpoint is the destination. This pulls the curve toward where your pointer just was, which visually smooths everything out.

Want even smoother output? Use cubic Bezier curves with bezierCurveTo. You'll need to track two previous points to compute control handles. For most signature use cases — contracts, consent forms, delivery confirmations — quadratic is plenty good enough and half the complexity.

Exporting Signatures as PNG or SVG

Canvas exports to a data URL with canvas.toDataURL('image/png'). That's a base64 string you can store in a database column (TEXT or BLOB), attach as a form field value, or POST directly to an API. For a typical signature at 600×200 at 96dpi, you're looking at roughly 8–20KB depending on ink density.

SVG export is trickier because canvas doesn't natively produce SVG. You have two options: record the path data yourself during drawing and serialize it as <path d="...">, or use a library like canvas2svg that shims the 2D context. The first approach is more code but zero extra dependency weight.

// Add this to your component
const exportAsSvg = useCallback(() => {
  // paths is a ref you populate during drawing
  // paths.current is an array of { points: {x,y}[], color: string, width: number }
  const svgParts = paths.current.map(({ points, color, width }) => {
    if (points.length < 2) return '';
    const d = points.reduce((acc, pt, i) => {
      if (i === 0) return `M ${pt.x} ${pt.y}`;
      const prev = points[i - 1];
      const midX = (prev.x + pt.x) / 2;
      const midY = (prev.y + pt.y) / 2;
      return `${acc} Q ${prev.x} ${prev.y} ${midX} ${midY}`;
    }, '');
    return `<path d="${d}" stroke="${color}" stroke-width="${width}" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`;
  });

  return [
    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`,
    ...svgParts,
    '</svg>',
  ].join('\n');
}, [width, height]);

If you're sending to a PDF service or e-signature platform like DocuSign's API, SVG is usually preferred. PNG works fine for visual records — receipts, audit logs, confirmations.

Styling With Tailwind v4 and Dark Mode

The canvas itself is just a box — style the wrapper. A standard treatment in Tailwind v4.0.2 looks like border border-zinc-200 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-900 overflow-hidden. The overflow-hidden clip prevents any browser-default canvas outline from leaking outside the border radius.

For the signature line (the baseline people sign on), you can either render it on the canvas in useEffect on mount, or fake it with a CSS pseudo-element on the wrapper. Drawing it on canvas means it persists in the export, which is usually what you want for legal documents.

Dark mode is worth thinking about. Your strokeColor prop defaults to #1a1a1a which is invisible on a dark canvas background. Either pass in a contrasting color (rgba(255,255,255,0.9) works well), or detect the current theme. If you're already using a theme toggle in your app, you can read the color scheme from a CSS variable and pass it down as a prop.

Integrating With React Hook Form

The cleanest way to connect a signature pad to a form is with a controlled component pattern and Controller from react-hook-form. The onEnd callback fires with the data URL — you call field.onChange(dataUrl) there, and the form tracks it as a string value.

import { useForm, Controller } from 'react-hook-form';
import { SignaturePad } from './SignaturePad';

type FormValues = {
  name: string;
  signature: string;
};

export function AgreementForm() {
  const { control, handleSubmit, register, formState: { errors } } = useForm<FormValues>();

  const onSubmit = (data: FormValues) => {
    // data.signature is the base64 PNG data URL
    console.log('Signature length:', data.signature.length);
    // POST to your API
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <input
        {...register('name', { required: 'Name is required' })}
        className="w-full border border-zinc-300 rounded-md px-3 py-2"
        placeholder="Full name"
      />

      <Controller
        name="signature"
        control={control}
        rules={{ required: 'Signature is required' }}
        render={({ field }) => (
          <div className="flex flex-col gap-2">
            <label className="text-sm font-medium text-zinc-700">Signature</label>
            <SignaturePad
              width={600}
              height={180}
              onEnd={(dataUrl) => field.onChange(dataUrl)}
              className="w-full border border-zinc-200 rounded-lg bg-white"
            />
            {errors.signature && (
              <p className="text-sm text-red-500">{errors.signature.message}</p>
            )}
          </div>
        )}
      />

      <button type="submit" className="px-6 py-2 bg-zinc-900 text-white rounded-lg">
        Submit
      </button>
    </form>
  );
}

One gotcha: field.onChange won't trigger re-validation automatically unless you set mode: 'onChange' in useForm. If validation only runs on submit, users might see no error until they hit the button — which is bad UX for a required signature field. For more on form patterns like this, the react-hook-form guide covers validation modes in detail.

Clear, Undo, and isEmpty Detection

Every signature pad needs a clear button. That's just ctx.clearRect(0, 0, canvas.width, canvas.height) and resetting your path tracking state. Expose it via a ref with useImperativeHandle if the parent needs to trigger it programmatically.

Undo is slightly more involved. Track each completed stroke as a snapshot — after pointerup, call canvas.toDataURL() and push that to a history array. On undo, pop the last item and draw it back with ctx.drawImage. Keep the history array capped at maybe 20 entries so you're not holding 20 full canvas snapshots in memory for a long session.

isEmpty detection is useful for validation. The naive approach is canvas.toDataURL() !== emptyDataUrl where you capture the empty state on mount. The faster approach: check if any pixel in the canvas has non-zero alpha using ctx.getImageData(0, 0, w, h).data and scanning for any value above 0. On a 600×200 canvas that's 480,000 values to scan — still fast enough at under 1ms in practice. What method makes most sense depends on how often you need to check.

Performance and Accessibility Considerations

Canvas drawing is inherently not accessible to screen readers. Add role="img" and aria-label="Signature input area" to the canvas element. For screen reader users, pair it with a typed name field as an alternative. This matters for WCAG 2.1 compliance on legal documents.

On performance — you'll see articles about using requestAnimationFrame to batch canvas renders. For signature pads, it's unnecessary overhead. Pointer events fire at roughly 60Hz on most devices, same as rAF. Direct drawing in the event handler is fine. Where performance actually matters is if you're rendering a live preview of the exported image elsewhere on the page — that extra toDataURL() call on every move event is expensive. Debounce it to 300ms or only call it on pointerup.

For mobile, test on both iOS Safari and Android Chrome. iOS Safari has quirks with pointer events inside scroll containers — touch-action: none on the canvas is required, but you may also need event.preventDefault() explicitly in pointerdown to stop scroll bleed. If you want to see how canvas-based interactions play with other visual effects on the page, the particles background article covers similar canvas coordination patterns.

FAQ

Can I use react-signature-canvas instead of building from scratch?

Yes, react-signature-canvas works fine and wraps signature_pad under the hood. The tradeoff is it adds ~14KB to your bundle and doesn't support the Pointer Events API natively (no stylus pressure). For simple use cases it's faster to set up. For anything requiring pressure sensitivity or SVG export, the custom approach gives you more control.

How do I store the signature in a database?

The PNG data URL is a base64 string — you can store it in a TEXT column directly. It'll be roughly 15–30KB for a typical signature. Alternatively, strip the data:image/png;base64, prefix, store the raw base64, and reconstruct the data URL when you read it back. For production, consider uploading to object storage (S3, R2) and storing just the URL.

The signature looks pixelated on Retina / high-DPI screens. How do I fix it?

Scale the canvas by window.devicePixelRatio. Set canvas.width = width * dpr and canvas.height = height * dpr, then call ctx.scale(dpr, dpr) after getting the context. Keep the CSS width/height at the original values so it displays at the right physical size. This is a one-time setup in useEffect.

How do I make the signature pad responsive to its container width?

Use a ResizeObserver on the wrapper div, read its contentRect.width, and update the canvas width accordingly. Store the current drawing as a data URL before resizing, then redraw it after resize with ctx.drawImage. The tricky part is the redraw scales the image — for legal signatures you might prefer to lock the canvas to a fixed aspect ratio instead.

Can I validate that the user actually drew something (not just clicked once)?

Track total path length during drawing. Sum the Euclidean distance between each consecutive point pair. If totalDistance < 50 (pixels) when pointerup fires, consider the pad empty. This catches single clicks and tiny scribbles without flagging deliberate short signatures.

Does this work in React Native?

No, the HTML Canvas API and Pointer Events don't exist in React Native. Use react-native-signature-canvas for mobile apps — it renders a WebView internally with the same canvas approach. If you're targeting web and native from one codebase, you'll need platform-specific implementations.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Charts in React with Recharts: Line, Bar, Pie, ResponsiveCamera and Webcam in React: Capture, Preview, UploadSignature Pad in React: Canvas Drawing, Export and Touch SupportReact UI Components Complete Reference: 60+ Patterns with Code