Aura Design System

Signature Pad

A component that allows the user to sign a document using a pen.

Preview

Loading...
import { useRef, useState } from "react";
import type { ComponentRef } from "react";
import SignaturePad from "@/components/ui/SignaturePad";
import { Button } from "@/components/ui/Button";
import { CheckIcon, SymbolIcon } from "@radix-ui/react-icons";

export function SignaturePadDemo() {
  return <SignaturePad />;
}

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/signature-pad

Manual

Install the following dependencies:

pnpm install @radix-ui/react-icons class-variance-authority

Copy and paste the button component into your components/ui/Button.tsx file.

/**
 * @description Displays a button or a component that looks like a button.
 */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";

import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/class-names";

const buttonVariants = cva("button", {
  variants: {
    variant: {
      default: "button-fill",
      fill: "button-fill",
      pill: "button-pill border border-gray-6 text-gray-11 bg-gray-2 hover:bg-gray-3",
      link: "button-link",
      menu: "button-menu",
    },
    size: {
      default: "h-4",
      xs: "h-2.5",
      sm: "h-3",
      md: "h-4",
      lg: "h-5",
      xl: "h-6",
      icon: "w-3 h-3 p-0",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "default",
  },
});

interface ButtonProps
  extends React.ComponentProps<"button">,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  isDisabled?: boolean;
  isLoading?: boolean;
  isLoadingText?: string | React.ReactNode;
  mode?: VariantProps<typeof buttonVariants>["variant"];
  label?: string | React.ReactNode;
}


const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (props: ButtonProps, ref) => {
    const {
      className,
      variant,
      mode,
      size,
      asChild = false,
      isDisabled,
      isLoading,
      isLoadingText,
      children,
      label,
      ...rest
    } = props;
    const Comp = asChild ? Slot : "button";
    const disabled = isDisabled || isLoading || props.disabled;
    const effectiveVariant = variant ?? mode;

    return (
      <Comp
        data-slot="button"
        className={cn(
          buttonVariants({ variant: effectiveVariant, size, className }),
          disabled && "opacity-50 cursor-not-allowed"
        )}
        ref={ref}
        disabled={disabled}
        {...rest}
      >
        {asChild ? (
          children
        ) : (
          <>
      
            {isLoading && isLoadingText ? isLoadingText : <>{label}{children}</>}
          </>
        )}
      </Comp>
    );
  }
);

Button.displayName = "Button";

export { Button, buttonVariants };
export type { ButtonProps };
export default Button;

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the SignaturePad component into your components/ui/SignaturePad.tsx file.

"use client";

import React, {
  type MouseEvent,
  type TouchEvent,
  useEffect,
  useImperativeHandle,
  useRef,
  useState,
} from "react";

import { cva, type VariantProps } from "class-variance-authority";
import { SymbolIcon, CheckIcon } from "@radix-ui/react-icons";

import { Button } from "@/components/ui/Button";
import { cn } from "@/utils/class-names";

const signaturePadVariants = cva("touch-none cursor-pencil", {
  variants: {
    variant: {
      default: "border border-gray-6 bg-gray-a2",
      ghost: "border-none bg-gray-a2",
      outline: "border border-gray-a6 bg-gray-1",
    },
    size: {
      default: "w-full h-[195px]",
      sm: "w-full h-[149.5px]",
      md: "w-full h-[247px]",
      lg: "w-full h-[299px]",
    },
  },
  defaultVariants: {
    variant: "default",
    size: "default",
  },
});

export interface SignaturePadProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange">,
    VariantProps<typeof signaturePadVariants> {
  /** @public (optional) - Tailwind color utility class for the pen color (e.g. "text-black", "text-primary-500") */
  penColor?: string;
  /** @public (optional) - Line width in pixels */
  lineWidth?: number;
  /** @public (optional) - Whether to show the buttons */
  showButtons?: boolean;
  /** @public (optional) - The icon to display for the save button */
  saveButtonIcon?: React.ReactNode;
  /** @public (optional) - The icon to display for the clear button */
  clearButtonIcon?: React.ReactNode;
  /** @public (optional) - Callback function to be called when the signature is saved */
  onSave?: (signature: Base64URLString) => void;
  /** @public (optional) - Callback function to be called when the signature is changed */
  onChange?: (signature: Base64URLString | null) => void;
}

interface SignaturePadRef {
  clear: () => void;
  save: () => void;
  toDataURL: () => Base64URLString | null;
  isEmpty: () => boolean;
  getCanvas: () => HTMLCanvasElement | null;
}

const SignaturePad = React.forwardRef<SignaturePadRef, SignaturePadProps>(
  (
    {
      penColor = "var(--accent-9)",
      lineWidth = 4,
      showButtons = true,
      saveButtonIcon,
      clearButtonIcon,
      variant,
      size,
      className,
      onSave,
      onChange,
      ...props
    },
    ref
  ) => {
    const [isDrawing, setIsDrawing] = useState(false);
    const [isEmpty, setIsEmpty] = useState(true);

    const pointsRef = useRef<{ x: number; y: number }[]>([]);
    const canvasRef = useRef<HTMLCanvasElement>(null);
    const ctxRef = useRef<CanvasRenderingContext2D | null>(null);

    // Helper function to resolve CSS variables to actual color values
    const resolveColor = (color: string): string => {
      if (!color.startsWith("var(")) {
        return color;
      }

      // Extract the variable name from var(--variable-name)
      const match = color.match(/var\((--[^)]+)\)/);
      if (!match) return color;

      const variableName = match[1];
      const canvas = canvasRef.current;
      if (!canvas) return color;

      // Get the computed style from the canvas element
      const computedStyle = getComputedStyle(canvas);
      const resolvedColor = computedStyle.getPropertyValue(variableName).trim();

      // If the variable is not found or empty, return the original
      return resolvedColor || color;
    };

    // Expose the clear, save, toDataURL, isEmpty, and getCanvas methods to the parent component
    useImperativeHandle(ref, () => ({
      clear: handleClear,
      save: handleSave,
      toDataURL: () => {
        const canvas = canvasRef.current;
        if (!canvas) return null;
        return canvas.toDataURL("image/png") as Base64URLString;
      },
      isEmpty: () => isEmpty,
      getCanvas: () => canvasRef.current,
    }));

    // Update the canvas size for High DPI displays
    useEffect(() => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      const updateCanvasSize = () => {
        const rect = canvas.getBoundingClientRect();
        const ratio = window.devicePixelRatio || 1;

        canvas.width = rect.width * ratio;
        canvas.height = rect.height * ratio;
        canvas.style.width = `${rect.width}px`;
        canvas.style.height = `${rect.height}px`;

        const ctx = canvas.getContext("2d");
        if (ctx) {
          ctx.scale(ratio, ratio);
          ctx.lineCap = "round";
          ctx.lineJoin = "round";
          ctx.strokeStyle = resolveColor(penColor);
          ctx.lineWidth = lineWidth;

          ctx.imageSmoothingEnabled = true;
          ctx.imageSmoothingQuality = "high";
          ctx.globalCompositeOperation = "source-over";

          ctxRef.current = ctx;
        }
      };

      updateCanvasSize();
      window.addEventListener("resize", updateCanvasSize);
      return () => {
        window.removeEventListener("resize", updateCanvasSize);
      };
    }, [penColor, lineWidth]);

    // Get the pointer position on the canvas
    const getPointerPosition = (e: MouseEvent | TouchEvent) => {
      const canvas = canvasRef.current;
      if (!canvas) return null;

      const rect = canvas.getBoundingClientRect();

      if ("touches" in e) {
        return {
          x: e.touches[0].clientX - rect.left,
          y: e.touches[0].clientY - rect.top,
        };
      }

      return {
        x: e.clientX - rect.left,
        y: e.clientY - rect.top,
      };
    };

    // Start drawing on the canvas
    const startDrawing = (e: MouseEvent | TouchEvent) => {
      e.preventDefault();

      const pointerPosition = getPointerPosition(e);

      if (!pointerPosition) return;

      setIsDrawing(true);
      pointsRef.current = [pointerPosition];
      setIsEmpty(false);
    };

    const draw = (e: MouseEvent | TouchEvent) => {
      e.preventDefault();
      if (!isDrawing) return;

      const canvas = canvasRef.current;
      let ctx = ctxRef.current;

      if (!ctx) ctx = canvas?.getContext("2d") as CanvasRenderingContext2D;

      // Ensure strokeStyle is set with resolved color
      if (ctx) {
        ctx.strokeStyle = resolveColor(penColor);
      }

      const newPoint = getPointerPosition(e);

      if (ctx && newPoint) {
        const updated = [...pointsRef.current, newPoint];

        if (updated.length < 2) {
          pointsRef.current = updated;
          return;
        }

        if (updated.length === 2) {
          ctx.beginPath();
          ctx.moveTo(updated[0].x, updated[0].y);
          ctx.lineTo(updated[1].x, updated[1].y);
          ctx.stroke();

          pointsRef.current = updated;
          return;
        }

        const previous = updated[updated.length - 3];
        const current = updated[updated.length - 2];
        const next = updated[updated.length - 1];

        const cp1x = (previous.x + current.x) / 2;
        const cp1y = (previous.y + current.y) / 2;

        const cp2x = (current.x + next.x) / 2;
        const cp2y = (current.y + next.y) / 2;

        ctx.beginPath();
        ctx.moveTo(cp1x, cp1y);
        ctx.quadraticCurveTo(current.x, current.y, cp2x, cp2y);
        ctx.stroke();

        pointsRef.current = updated.slice(-3);
        return;
      }
    };

    const stopDrawing = () => {
      setIsDrawing(false);
      pointsRef.current = [];
      if (isDrawing) {
        onChange?.(
          canvasRef.current?.toDataURL("image/png") as Base64URLString
        );
      }
    };

    const handleClear = () => {
      const canvas = canvasRef.current;
      if (!canvas) return;

      const ctx = canvas.getContext("2d");
      if (!ctx) return;

      ctx.clearRect(0, 0, canvas.width, canvas.height);
      setIsEmpty(true);
      onChange?.(null);
    };

    const handleSave = () => {
      const canvas = canvasRef.current;
      if (!canvas && isEmpty) return;

      const dataURL = canvas?.toDataURL("image/png");
      onSave?.(dataURL as Base64URLString);
    };

    return (
      <div className={cn("w-full relative", className)} {...props}>
        <canvas
          ref={canvasRef}
          className={cn(
            "rounded-lg cursor-pencil",
            signaturePadVariants({ variant, size })
          )}
          onMouseDown={startDrawing}
          onMouseMove={draw}
          onMouseUp={stopDrawing}
          onMouseLeave={stopDrawing}
          onTouchStart={startDrawing}
          onTouchMove={draw}
          onTouchEnd={stopDrawing}
        />

        {showButtons && (
          <div className="absolute bottom-1 right-1 flex gap-0.5">
            <Button
              variant="pill"
              size="sm"
              onClick={handleClear}
              className="rounded-full"
              type="button"
            >
              {clearButtonIcon || <SymbolIcon />}
            </Button>
            <Button
              variant="pill"
              size="sm"
              onClick={handleSave}
              className="rounded-full"
              type="button"
            >
              {saveButtonIcon || <CheckIcon />}
            </Button>
          </div>
        )}
      </div>
    );
  }
);

export default SignaturePad;

Usage

WithoutButtons

export const WithoutButtons = () => <SignaturePad showButtons={false} />;

Variants

export const Variants = () => (
  <div className="flex flex-col gap-4">
    <div>
      <p className="text-sm font-medium mb-2">Default</p>
      <SignaturePad variant="default" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Ghost</p>
      <SignaturePad variant="ghost" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Outline</p>
      <SignaturePad variant="outline" />
    </div>
  </div>
)

Sizes

export const Sizes = () => (
  <div className="flex flex-col gap-4">
    <div>
      <p className="text-sm font-medium mb-2">Small</p>
      <SignaturePad size="sm" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Default</p>
      <SignaturePad size="default" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Medium</p>
      <SignaturePad size="md" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Large</p>
      <SignaturePad size="lg" />
    </div>
  </div>
)

CustomPenColor

export const CustomPenColor = () => (
  <div className="flex flex-col gap-4">
    <div>
      <p className="text-sm font-medium mb-2">Gray 9</p>
      <SignaturePad penColor="var(--gray-9)" />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Accent 9</p>
      <SignaturePad penColor="var(--accent-9)" />
    </div>

  </div>
)

CustomLineWidth

export const CustomLineWidth = () => (
  <div className="flex flex-col gap-4">
    <div>
      <p className="text-sm font-medium mb-2">Thin (2px)</p>
      <SignaturePad lineWidth={2} />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Default (4px)</p>
      <SignaturePad lineWidth={4} />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Thick (6px)</p>
      <SignaturePad lineWidth={6} />
    </div>
    <div>
      <p className="text-sm font-medium mb-2">Very Thick (8px)</p>
      <SignaturePad lineWidth={8} />
    </div>
  </div>
)

WithCustomIcons

export const WithCustomIcons = () => (
  <SignaturePad
    saveButtonIcon={<CheckIcon />}
    clearButtonIcon={<SymbolIcon />}
  />
)

WithOnSave

export const WithOnSave = () => {
  const [savedSignature, setSavedSignature] = useState<string | null>(null);

  return (
    <div className="flex flex-col gap-4">
      <SignaturePad
        onSave={(signature) => {
          setSavedSignature(signature);
          console.log("Signature saved:", signature);
        }}
      />
      {savedSignature && (
        <div className="mt-4">
          <p className="text-sm font-medium mb-2">Saved Signature:</p>
          <img
            src={savedSignature}
            alt="Saved signature"
            className="border border-gray-6 rounded-lg"
          />
        </div>
      )}
    </div>
  );
};

WithOnChange

export const WithOnChange = () => {
  const [hasSignature, setHasSignature] = useState(false);

  return (
    <div className="flex flex-col gap-4">
      <SignaturePad
        onChange={(signature) => {
          setHasSignature(signature !== null);
          console.log("Signature changed:", signature ? "Has signature" : "Empty");
        }}
      />
      <div className="text-sm">
        Status: {hasSignature ? "✓ Has signature" : "Empty"}
      </div>
    </div>
  );
};

WithRefMethods

export const WithRefMethods = () => {
  const signaturePadRef = useRef<ComponentRef<typeof SignaturePad>>(null);
  const [signatureData, setSignatureData] = useState<string | null>(null);
  const [isEmpty, setIsEmpty] = useState(true);

  const handleClear = () => {
    signaturePadRef.current?.clear();
    setSignatureData(null);
    setIsEmpty(true);
  };

  const handleSave = () => {
    signaturePadRef.current?.save();
    const dataURL = signaturePadRef.current?.toDataURL();
    if (dataURL) {
      setSignatureData(dataURL);
    }
  };

  const handleCheckEmpty = () => {
    const empty = signaturePadRef.current?.isEmpty() ?? true;
    setIsEmpty(empty);
  };

  return (
    <div className="flex flex-col gap-4">
      <SignaturePad ref={signaturePadRef} showButtons={false} />
      <div className="flex gap-2">
        <Button onClick={handleClear} variant="outline" size="sm">
          Clear (via ref)
        </Button>
        <Button onClick={handleSave} variant="outline" size="sm">
          Save (via ref)
        </Button>
        <Button onClick={handleCheckEmpty} variant="outline" size="sm">
          Check Empty
        </Button>
      </div>
      <div className="text-sm">
        Is Empty: {isEmpty ? "Yes" : "No"}
      </div>
      {signatureData && (
        <div>
          <p className="text-sm font-medium mb-2">Saved Signature:</p>
          <img
            src={signatureData}
            alt="Saved signature"
            className="border border-gray-6 rounded-lg max-w-xs"
          />
        </div>
      )}
    </div>
  );
};

CombinedExample

export const CombinedExample = () => {
  const [signature, setSignature] = useState<string | null>(null);

  return (
    <div className="flex flex-col gap-4 max-w-md">
      <div>
        <p className="text-sm font-medium mb-2">Sign below:</p>
        <SignaturePad
          variant="outline"
          size="md"
          penColor="hsl(var(--foreground))"
          lineWidth={3}
          onSave={(dataURL) => {
            setSignature(dataURL);
            alert("Signature saved!");
          }}
          onChange={(dataURL) => {
            console.log("Signature changed");
          }}
        />
      </div>
      {signature && (
        <div>
          <p className="text-sm font-medium mb-2">Your signature:</p>
          <img
            src={signature}
            alt="Your signature"
            className="border border-gray-6 rounded-lg"
          />
        </div>
      )}
    </div>
  );
};