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-padManual
Install the following dependencies:
pnpm install @radix-ui/react-icons class-variance-authorityCopy 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>
);
};