Bloom Logo

Signature Input

A canvas-based interactive signature capture pad supporting custom styling, undo history, clear options, and base64 export.

importimport { SignatureInput } from "@/components/ui/signatureInput/signatureInput";
$npx @bloomui-react/cli add signatureInput
signatureInput.tsx
"use client";

import { Icon } from "@iconify/react";
import * as React from "react";
import { Button } from "@/components/ui/button/button";
import { designRadius } from "@/lib/design-system";
import { cn } from "@/lib/utils";

export interface SignatureInputProps {
  className?: string;
  width?: number | string;
  height?: number | string;
  strokeColor?: string;
  lineWidth?: number;
  backgroundColor?: string;
  variant?: "default" | "bordered" | "flat";
  radius?: keyof typeof designRadius;
  onChange?: (signature: string | null) => void;
  onClear?: () => void;
  placeholder?: string;
  isDisabled?: boolean;
  label?: React.ReactNode;
  isRequired?: boolean;
}

interface Point {
  x: number;
  y: number;
}

export const SignatureInput = React.forwardRef<
  HTMLCanvasElement,
  SignatureInputProps
>(
  (
    {
      className,
      width = "100%",
      height = 200,
      strokeColor = "currentColor",
      lineWidth = 2.5,
      backgroundColor = "transparent",
      variant = "default",
      radius = "md",
      onChange,
      onClear,
      placeholder = "Sign here...",
      isDisabled = false,
      label,
      isRequired = false,
    },
    ref,
  ) => {
    const canvasRef = React.useRef<HTMLCanvasElement>(null);
    const [isDrawing, setIsDrawing] = React.useState(false);
    const [history, setHistory] = React.useState<string[]>([]);
    const [isEmpty, setIsEmpty] = React.useState(true);

    const getCoordinates = (
      e:
        | React.MouseEvent<HTMLCanvasElement>
        | React.TouchEvent<HTMLCanvasElement>,
    ): Point | null => {
      const canvas = canvasRef.current;
      if (!canvas) return null;

      const rect = canvas.getBoundingClientRect();
      const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
      const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;

      const scaleX = canvas.width / rect.width;
      const scaleY = canvas.height / rect.height;

      return {
        x: (clientX - rect.left) * scaleX,
        y: (clientY - rect.top) * scaleY,
      };
    };

    const startDrawing = (
      e:
        | React.MouseEvent<HTMLCanvasElement>
        | React.TouchEvent<HTMLCanvasElement>,
    ) => {
      if (isDisabled) return;
      e.preventDefault();

      const coords = getCoordinates(e);
      if (!coords) return;

      const canvas = canvasRef.current;
      const ctx = canvas?.getContext("2d");
      if (!canvas || !ctx) return;

      saveState();

      ctx.beginPath();
      ctx.moveTo(coords.x, coords.y);
      ctx.lineWidth = lineWidth;
      ctx.lineCap = "round";
      ctx.lineJoin = "round";

      if (strokeColor === "currentColor") {
        const style = window.getComputedStyle(canvas);
        ctx.strokeStyle = style.color;
      } else {
        ctx.strokeStyle = strokeColor;
      }

      setIsDrawing(true);
      setIsEmpty(false);
    };

    const draw = (
      e:
        | React.MouseEvent<HTMLCanvasElement>
        | React.TouchEvent<HTMLCanvasElement>,
    ) => {
      if (!isDrawing || isDisabled) return;
      e.preventDefault();

      const coords = getCoordinates(e);
      if (!coords) return;

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

      ctx.lineTo(coords.x, coords.y);
      ctx.stroke();
    };

    const stopDrawing = () => {
      if (!isDrawing) return;
      setIsDrawing(false);
      triggerChange();
    };

    const saveState = () => {
      const canvas = canvasRef.current;
      if (!canvas) return;
      setHistory((prev) => [...prev, canvas.toDataURL()]);
    };

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

      if (isEmpty) {
        onChange?.(null);
      } else {
        onChange?.(canvas.toDataURL());
      }
    };

    const clear = () => {
      const canvas = canvasRef.current;
      const ctx = canvas?.getContext("2d");
      if (!canvas || !ctx) return;

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

    const undo = () => {
      const canvas = canvasRef.current;
      const ctx = canvas?.getContext("2d");
      if (!canvas || !ctx || history.length === 0) return;

      const newHistory = [...history];
      const prevState = newHistory.pop();
      setHistory(newHistory);

      if (prevState) {
        const img = new Image();
        img.onload = () => {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          ctx.drawImage(img, 0, 0);
          triggerChange();
        };
        img.src = prevState;
      } else {
        clear();
      }
    };

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

      const rect = canvas.getBoundingClientRect();
      canvas.width = rect.width;
      canvas.height = rect.height;

      const ctx = canvas.getContext("2d");
      if (ctx && backgroundColor !== "transparent") {
        ctx.fillStyle = backgroundColor;
        ctx.fillRect(0, 0, canvas.width, canvas.height);
      }
    }, [backgroundColor]);

    return (
      <div className={cn("flex flex-col gap-1.5 w-full", className)}>
        {label && (
          <label className="text-xs font-semibold text-zinc-900 dark:text-zinc-100 select-none">
            {label}
            {isRequired && <span className="text-rose-500 ml-0.5">*</span>}
          </label>
        )}
        <div
          className={cn(
            "relative border overflow-hidden flex flex-col group text-zinc-900 dark:text-zinc-100",
            designRadius[radius],
            variant === "default" &&
              "bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 shadow-xs",
            variant === "bordered" &&
              "bg-transparent border-2 border-zinc-200 dark:border-zinc-800",
            variant === "flat" &&
              "bg-zinc-100 dark:bg-zinc-800/60 border-transparent hover:bg-zinc-200/70 dark:hover:bg-zinc-800",
            isDisabled && "opacity-50 pointer-events-none cursor-not-allowed",
          )}
        >
          {isEmpty && (
            <div className="absolute inset-0 flex items-center justify-center pointer-events-none select-none text-zinc-400 dark:text-zinc-500 font-normal text-sm">
              {placeholder}
            </div>
          )}

          <canvas
            ref={(node) => {
              if (typeof ref === "function") ref(node);
              else if (ref) (ref as any).current = node;
              (canvasRef as any).current = node;
            }}
            style={{ width, height }}
            className="block cursor-crosshair touch-none"
            onMouseDown={startDrawing}
            onMouseMove={draw}
            onMouseUp={stopDrawing}
            onMouseLeave={stopDrawing}
            onTouchStart={startDrawing}
            onTouchMove={draw}
            onTouchEnd={stopDrawing}
          />

          <div className="absolute bottom-2 right-2 flex items-center gap-1.5 opacity-80 hover:opacity-100 transition-opacity">
            <Button
              size="sm"
              variant="flat"
              isDisabled={history.length === 0 || isDisabled}
              onClick={undo}
              className="h-7 w-7 p-0"
              aria-label="Undo last stroke"
            >
              <Icon icon="lucide:undo-2" className="size-4" />
            </Button>
            <Button
              size="sm"
              variant="flat"
              isDisabled={isEmpty || isDisabled}
              onClick={clear}
              className="h-7 w-7 p-0 text-danger hover:bg-danger/10"
              aria-label="Clear signature"
            >
              <Icon icon="lucide:trash-2" className="size-4" />
            </Button>
          </div>
        </div>
      </div>
    );
  },
);

SignatureInput.displayName = "SignatureInput";

Default

A simple signature pad with drawing capabilities, clear, and undo functionality.

onChange: (signature: string | null) => voidplaceholder: string
Sign inside this box...

Variants

Supports different wrapper card variations including default shadow card, bordered card, and flat background card.

variant: default | bordered | flat
variant="default"
Default variant...
variant="bordered"
Bordered variant...
variant="flat"
Flat variant...

Customizations

Customize pen stroke color, line width, and bounding height.

strokeColor: stringlineWidth: numberheight: number | string
strokeColor="#3b82f6" (Blue) & lineWidth=4
Thick blue pen...
strokeColor="#ec4899" (Pink) & lineWidth=1.5
Thin pink pen...

Disabled State

Signature input is rendered non-interactive, preserving any current content.

isDisabled: boolean
Signature pad is disabled

Required State

Displays an asterisk next to the label indicating that signing is mandatory.

isRequired: boolean
Sign here...

Props — SignatureInput

Properties for configuring the signature input.

PropTypeDefaultDescription
widthnumber | string"100%"CSS width of the canvas wrapper.
heightnumber | string200CSS height of the canvas drawing area.
strokeColorstring"currentColor"Hex or named color of the signature stroke. Uses current font color if "currentColor".
lineWidthnumber2.5Width/thickness of the drawing line in pixels.
variant"default" | "bordered" | "flat""default"The styling variant of the signature wrapper box.
radiuskeyof typeof designRadius"md"Corner radius of the signature card.
onChange(signature: string | null) => void-Callback containing base64 data URL png output of signature when drawing ends, or null when cleared.
onClear() => void-Callback fired when the signature is cleared.
isDisabledbooleanfalseDisables all pointer interactions and fades the component out slightly.