Signature Draw Capture
The scrawl strokes on at hand speed and the baseline confirms once the pen lifts.
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.
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.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Checkout
The signing pad confirms only after the finger leaves the glass.
Related patterns
- Date Picker OpenThe calendar unfolds from the field and the days arrive in a wave, today already marked.
- Color Picker SwatchA ring grows around the chosen swatch and travels to the next one, while the preview eases to the new color.
- Currency Input FormatSeparators fade in where they belong as the amount groups itself, and the caret holds its place.