signature-pad

PreviousNext

A signature pad component for capturing signatures

Docs
shadix-uicomponent

Preview

Loading preview…
registry/new-york/components/signature-pad.tsx
"use client";

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

import { cva, type VariantProps } from "class-variance-authority";
import { BrushCleaning, Save } from "lucide-react";

import { Button } from "@/shadcn/components/ui/button";
import { cn } from "@/shadcn/lib/utils";

const signaturePadVariants = cva("touch-none cursor-pencil", {
    variants: {
        variant: {
            default: "border border-input bg-input/30",
            ghost: "border-none bg-muted/50",
            outline: "border-2 border-primary bg-background",
        },
        size: {
            default: "w-full h-[200px]",
            sm: "w-full h-[150px]",
            md: "w-full h-[250px]",
            lg: "w-full h-[300px]",
        },
    },
    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;
}

/**
 * Signature Pad component
 * @param {SignaturePadProps} props - The props for the SignaturePad component
 * @param {React.Ref<SignaturePadRef>} ref - The ref for the SignaturePad component
 * @returns {React.ReactNode} The SignaturePad component
 *
 * @requires Add this import to your global CSS:
 * @import '@styles/signature-pad.css'; // or relative path to the styles file based on your components.json file
 *
 * Or add this to your tailwind.config.ts:
 * theme: {
 *   extend: {
 *     cursor: {
 *       pencil: 'url("data:image/svg+xml...") 0 24, pointer'
 *     }
 *   }
 * }
 *
 * @example
 * <SignaturePad
 *     penColor="hsl(var(--foreground))"
 *     lineWidth={4}
 *     showButtons={true}
 *     saveButtonIcon={<Save />}
 *     clearButtonIcon={<BrushCleaning />}
 * />
 */

const SignaturePad = React.forwardRef<SignaturePadRef, SignaturePadProps>(
    (
        {
            penColor = "hsl(var(--foreground))",
            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);

        // 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 = 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;

            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-2 right-2 flex gap-1">
                        <Button
                            variant="outline"
                            size="icon-sm"
                            onClick={handleClear}
                            className="rounded-full"
                        >
                            {clearButtonIcon || <BrushCleaning />}
                        </Button>
                        <Button
                            variant="outline"
                            size="icon-sm"
                            onClick={handleSave}
                            className="rounded-full"
                        >
                            {saveButtonIcon || <Save />}
                        </Button>
                    </div>
                )}
            </div>
        );
    },
);

export default SignaturePad;

Installation

npx shadcn@latest add @shadix-ui/signature-pad

Usage

import { SignaturePad } from "@/components/signature-pad"
<SignaturePad />