All patterns

Signature Draw Capture

The scrawl strokes on at hand speed and the baseline confirms once the pen lifts.

formspremiumelegantinteraction · finite · intermediate · ~1.9s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

290 lines · react + motion only
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Signature Draw Capture
 *
 * A signing pad that puts the stroke down the way a hand would: the main
 * scrawl runs first, the flourish follows it, and only once the pen is
 * lifted does the baseline confirm and the capture line appear.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, and the ink is `currentColor`, so
 * the signature reads on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `signatory`, `capturedOn`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type SignatureDrawCaptureProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Name printed under the baseline. */
  signatory?: string;
  /** Date shown once the signature is captured. */
  capturedOn?: string;
  /** Prompt shown on the empty pad. */
  prompt?: string;
  /** Accent for the confirmed baseline. */
  accent?: string;
  /** Fires when the stroke has finished and the baseline confirms. */
  onCapture?: () => void;
};

type VariantConfig = {
  /** Seconds the main stroke takes. */
  strokeSeconds: number;
  /** Seconds the flourish takes, once the main stroke lands. */
  flourishSeconds: number;
  /** Seconds the baseline takes to confirm. */
  baselineSeconds: number;
};

// Quality rule: nothing springs. A signature is a record, and a stroke
// that overshoots and settles back would be drawing something the signer
// did not write. The confirmation copy is text: it fades, and never
// scales or bounces in.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A quick hand. For a pad people sign several times a day.
  subtle: { strokeSeconds: 0.75, flourishSeconds: 0.22, baselineSeconds: 0.22 },
  // The pace of a real signature. The all-purpose setting.
  default: { strokeSeconds: 1.25, flourishSeconds: 0.34, baselineSeconds: 0.3 },
  // Slower and more deliberate, for a contract screen where the signing
  // is the moment.
  playful: { strokeSeconds: 1.8, flourishSeconds: 0.48, baselineSeconds: 0.42 },
};

/** Theme-adaptive neutral: `currentColor` is the text color this
 *  component inherits — near-black on a light page, near-white on a dark
 *  one — so mixing it with `transparent` yields a surface, border or fill
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const MAIN_STROKE =
  "M14 46C16 26 22 13 30 13c8 0 8 13 0 19-6 4-10 4-12 4 8 0 16 4 22 10 6 6 12 4 16-4 4-8 8-12 14-8 6 4 4 12 10 12 8 0 12-12 18-16 6-4 10 2 8 8-2 6 4 10 12 6 8-4 14-14 22-14 8 0 8 10 2 14-6 4-2 6 6 2 12-6 22-12 34-8 10 3 14 8 24 4";
const FLOURISH_STROKE = "M96 21c24-4 54-2 76 2";

export default function SignatureDrawCapture({
  variant = "default",
  signatory = "Rowan Ellis · Account holder",
  capturedOn = "18 August 2026",
  prompt = "Press to sign",
  accent = "#5B5BD6",
  onCapture,
}: SignatureDrawCaptureProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const [signed, setSigned] = useState(false);
  const [lifted, setLifted] = useState(false);
  const [ring, setRing] = useState<string | null>(null);

  const sign = () => {
    if (signed) return;
    setSigned(true);
    if (reduceMotion) {
      setLifted(true);
      onCapture?.();
      return;
    }
    // The pen has to leave the paper before anything confirms: the
    // baseline and the capture line wait for the last stroke to land.
    window.setTimeout(
      () => {
        setLifted(true);
        onCapture?.();
      },
      (cfg.strokeSeconds + cfg.flourishSeconds) * 1000
    );
  };

  const clear = () => {
    setSigned(false);
    setLifted(false);
  };

  const pad = (
    <svg
      viewBox="0 0 240 72"
      width="100%"
      height="90"
      fill="none"
      aria-hidden
      style={{ display: "block" }}
    >
      <motion.path
        d={MAIN_STROKE}
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        strokeLinejoin="round"
        initial={{ pathLength: 0, opacity: 0.9 }}
        animate={{ pathLength: signed ? 1 : 0 }}
        transition={{
          duration: reduceMotion ? 0 : cfg.strokeSeconds,
          ease: [0.4, 0.05, 0.35, 1],
        }}
      />
      <motion.path
        d={FLOURISH_STROKE}
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        initial={{ pathLength: 0, opacity: 0.75 }}
        animate={{ pathLength: signed ? 1 : 0 }}
        transition={{
          duration: reduceMotion ? 0 : cfg.flourishSeconds,
          delay: reduceMotion || !signed ? 0 : cfg.strokeSeconds,
          ease: "easeOut",
        }}
      />
    </svg>
  );

  return (
    <div style={{ width: 300, color: "inherit" }}>
      <div
        style={{
          position: "relative",
          borderRadius: 13,
          border: `1px solid ${lifted ? tone(16) : tone(13)}`,
          background: tone(5),
          overflow: "hidden",
        }}
      >
        {signed ? (
          <div style={{ padding: "14px 14px 20px" }}>{pad}</div>
        ) : (
          <button
            type="button"
            onClick={sign}
            onFocus={(event) =>
              setRing(event.currentTarget.matches(":focus-visible") ? "pad" : null)
            }
            onBlur={() => setRing(null)}
            style={{
              position: "relative",
              display: "block",
              width: "100%",
              padding: "14px 14px 20px",
              fontFamily: "inherit",
              color: "inherit",
              background: "transparent",
              border: "none",
              cursor: "pointer",
              boxShadow: ring === "pad" ? `inset 0 0 0 2px ${accent}` : "none",
              outline: "none",
            }}
          >
            {pad}
            <span
              style={{
                position: "absolute",
                inset: 0,
                display: "grid",
                placeItems: "center",
                fontSize: 12,
                opacity: 0.42,
                pointerEvents: "none",
              }}
            >
              {prompt}
            </span>
          </button>
        )}

        {/* The baseline is always there; confirming it means drawing the
            accent along it from the left, the direction the hand moved. */}
        <span
          aria-hidden
          style={{
            position: "absolute",
            left: 16,
            right: 16,
            bottom: 16,
            height: 1.5,
            borderRadius: 1,
            background: tone(18),
          }}
        />
        <motion.span
          aria-hidden
          initial={false}
          animate={{ scaleX: lifted ? 1 : 0 }}
          transition={{
            duration: reduceMotion ? 0 : cfg.baselineSeconds,
            ease: "easeOut",
          }}
          style={{
            position: "absolute",
            left: 16,
            right: 16,
            bottom: 16,
            height: 1.5,
            borderRadius: 1,
            background: accent,
            transformOrigin: "left",
          }}
        />
      </div>

      <div
        style={{
          display: "flex",
          alignItems: "center",
          gap: 10,
          marginTop: 9,
          minHeight: 26,
        }}
      >
        <div style={{ minWidth: 0, flex: 1 }}>
          <div style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.65 }}>
            {signatory}
          </div>
          <motion.div
            role="status"
            initial={false}
            animate={{ opacity: lifted ? 1 : 0 }}
            transition={{
              duration: reduceMotion ? 0 : 0.24,
              delay: lifted && !reduceMotion ? cfg.baselineSeconds * 0.6 : 0,
              ease: "easeOut",
            }}
            style={{ fontSize: 11, opacity: 0, color: accent, fontWeight: 650 }}
          >
            Signature captured · {capturedOn}
          </motion.div>
        </div>

        <motion.button
          type="button"
          onClick={clear}
          initial={false}
          animate={{ opacity: lifted ? 1 : 0 }}
          transition={{ duration: reduceMotion ? 0 : 0.2, ease: "easeOut" }}
          disabled={!lifted}
          onFocus={(event) =>
            setRing(event.currentTarget.matches(":focus-visible") ? "clear" : null)
          }
          onBlur={() => setRing(null)}
          style={{
            flex: "0 0 auto",
            padding: "5px 11px",
            fontFamily: "inherit",
            fontSize: 11.5,
            fontWeight: 620,
            color: "inherit",
            background: tone(7),
            border: `1px solid ${tone(13)}`,
            borderRadius: 8,
            cursor: lifted ? "pointer" : "default",
            pointerEvents: lifted ? "auto" : "none",
            boxShadow: ring === "clear" ? `0 0 0 3px ${tone(20)}` : "none",
            outline: "none",
          }}
        >
          Clear
        </motion.button>
      </div>
    </div>
  );
}

About this pattern

Signing, staged the way a hand does it. The main stroke runs first at writing pace, the flourish follows it, and nothing confirms until the pen has left the paper — then the accent runs along the baseline in the direction the hand moved and the capture line fades in beneath it. Nothing springs anywhere in the sequence: a signature is a record, and a stroke that overshoots and settles back would be drawing something the signer did not write. The ink is the page's own text colour, so it reads on a light sheet and on a dark one.

Sign an agreementDelivery acceptanceConsent captureContract signing step

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Payment
    Card number4242 4242 4242 4242Name on cardN. Bergström
    Expiry04 / 28CVC•••
    Subtotal$156.00Shipping$0.00Tax$13.65Total$169.65
    Pay $169.65
    Checkout

    The signing pad confirms only after the finger leaves the glass.

Related patterns