Text Effects
SignatureWrite
A pen signs the name as one running line, drawn from its own handwriting, not a font. It goes back for the dots and crosses, then closes with a swoosh.
Download
- React
- Tailwind CSS v4
- motion
components/ui/signature-write.tsx
"use client";
import { useEffect, useMemo, useRef } from "react";
import { animate, useInView, useMotionValue, useReducedMotion } from "motion/react";
type SignatureWriteProps = {
/** The name to sign. A to Z only; anything else leaves a gap on screen, screen readers still get it. */
text: string;
className?: string;
/** Seconds for the whole signature. */
duration?: number;
/** Add the closing swoosh under the name. */
flourish?: boolean;
/** Show the dotted sign-here line. */
line?: boolean;
/** Play only the first time it scrolls into view. */
once?: boolean;
};
type Glyph = { w: number; d: string; marks?: string[] };
// One pen line per letter. Each starts at 0,0 and ends at w,0, so a word joins into one unbroken stroke.
// marks are lifted strokes done after the word: i dots, t and x crosses.
const SMALL: Record<string, Glyph> = {
a: { w: 30, d: "C 6 -4 12 -14 18 -24 C 12 -30 1 -24 2 -8 C 3 4 13 1 18 -24 C 16 -10 16 4 22 4 C 25 4 28 2 30 0" },
b: { w: 28, d: "C 8 -12 18 -38 20 -50 C 21 -60 13 -60 11 -50 C 8 -34 8 -10 9 0 C 11 6 20 5 23 -6 C 24 -14 19 -18 14 -14 C 17 -9 22 -4 28 0" },
c: { w: 24, d: "C 6 -6 12 -16 18 -24 C 18 -27 14 -27 11 -25 C 4 -20 2 -6 5 0 C 8 6 16 5 24 0" },
d: { w: 30, d: "C 6 -4 12 -14 18 -24 C 12 -30 1 -24 2 -8 C 3 4 13 1 18 -16 C 20 -30 22 -48 23 -58 C 21 -40 18 -10 20 0 C 22 6 27 3 30 0" },
e: { w: 22, d: "C 6 -2 14 -10 15 -18 C 16 -26 8 -26 5 -18 C 2 -8 4 4 12 4 C 16 4 19 2 22 0" },
f: { w: 20, d: "C 6 -6 14 -36 15 -50 C 16 -62 9 -60 8 -48 C 7 -30 7 10 6 28 C 5 38 -1 36 1 26 C 3 14 12 6 20 0" },
g: { w: 28, d: "C 6 -4 12 -14 18 -24 C 12 -30 1 -24 2 -8 C 3 3 13 0 18 -24 C 17 -4 16 18 12 30 C 8 40 0 36 3 26 C 6 16 18 6 28 0" },
h: { w: 30, d: "C 8 -12 18 -38 20 -50 C 21 -60 13 -60 11 -50 C 8 -34 7 -14 6 2 C 9 -14 14 -26 20 -24 C 25 -22 20 -6 22 0 C 23 5 27 3 30 0" },
i: { w: 14, d: "C 4 -6 7 -16 9 -24 C 7 -14 6 -2 8 2 C 10 5 12 2 14 0", marks: ["M 10 -36 l 0.5 0.5"] },
j: { w: 18, d: "C 4 -6 8 -16 10 -24 C 9 -6 8 16 5 28 C 3 38 -4 36 -2 26 C 1 14 10 6 18 0", marks: ["M 11 -36 l 0.5 0.5"] },
k: { w: 30, d: "C 8 -12 18 -38 20 -50 C 21 -60 13 -60 11 -50 C 8 -34 7 -14 6 3 C 8 -12 14 -26 21 -24 C 25 -22 20 -14 11 -12 C 16 -10 18 3 23 4 C 26 4 28 2 30 0" },
l: { w: 18, d: "C 8 -12 16 -38 17 -50 C 18 -60 11 -60 9 -50 C 6 -30 6 -6 8 1 C 10 5 15 3 18 0" },
m: { w: 42, d: "C 3 -6 6 -16 8 -24 C 8 -16 7 -6 7 3 C 10 -12 14 -24 19 -24 C 23 -23 20 -8 20 3 C 23 -12 27 -24 32 -24 C 36 -22 32 -6 35 1 C 37 4 40 2 42 0" },
n: { w: 30, d: "C 3 -6 6 -16 8 -24 C 8 -16 7 -6 7 3 C 10 -12 14 -24 19 -24 C 24 -22 20 -6 23 1 C 25 4 28 2 30 0" },
o: { w: 26, d: "C 6 -4 12 -14 16 -25 C 9 -28 1 -20 3 -6 C 5 4 16 3 18 -10 C 19 -20 17 -25 14 -25 C 16 -18 20 -6 26 0" },
p: { w: 30, d: "C 4 -6 7 -16 9 -24 C 8 -8 6 16 3 34 C 6 8 10 -20 18 -24 C 26 -26 26 -2 15 3 C 12 4 10 2 10 0 C 16 2 24 2 30 0" },
q: { w: 28, d: "C 6 -4 12 -14 18 -24 C 12 -30 1 -24 2 -8 C 3 3 13 0 18 -24 C 17 -4 15 20 14 34 C 18 20 22 8 28 0" },
r: { w: 24, d: "C 4 -8 8 -18 10 -27 C 12 -24 15 -24 17 -22 C 16 -12 15 -4 18 1 C 20 4 22 2 24 0" },
s: { w: 22, d: "C 4 -8 9 -18 13 -27 C 16 -18 19 -8 15 0 C 11 6 4 5 3 0 C 8 4 16 3 22 0" },
t: { w: 20, d: "C 5 -8 9 -26 11 -44 C 9 -28 7 -8 9 1 C 11 5 16 3 20 0", marks: ["M 2 -28 C 8 -30 14 -30 20 -30"] },
u: { w: 30, d: "C 3 -6 6 -16 8 -24 C 6 -14 5 -2 8 2 C 12 5 17 -8 20 -24 C 18 -12 18 -2 21 2 C 24 4 27 2 30 0" },
v: { w: 26, d: "C 3 -6 6 -16 8 -24 C 6 -14 6 -2 10 2 C 15 4 20 -10 19 -22 C 20 -14 22 -4 26 0" },
w: { w: 38, d: "C 3 -6 6 -16 8 -24 C 6 -14 5 -2 8 2 C 11 5 15 -6 17 -18 C 16 -8 17 0 20 2 C 25 4 30 -10 29 -22 C 30 -14 33 -4 38 0" },
x: { w: 26, d: "C 5 -6 9 -18 13 -24 C 16 -22 12 -6 14 0 C 16 5 22 3 26 0", marks: ["M 20 -24 C 16 -14 10 -4 6 3"] },
y: { w: 28, d: "C 3 -6 6 -16 8 -24 C 6 -14 5 -2 8 2 C 12 5 17 -8 20 -24 C 19 -4 17 18 13 30 C 9 40 1 36 4 26 C 7 16 18 6 28 0" },
z: { w: 24, d: "C 5 -6 10 -20 16 -24 C 12 -20 8 -8 8 -2 C 14 -6 17 2 14 12 C 11 24 3 24 5 14 C 7 6 16 3 24 0" },
};
const STEM = "M 12 -60 C 12 -40 10 -16 6 2";
const BIG: Record<string, { w: number; strokes: string[] }> = {
A: { w: 40, strokes: ["M 0 2 C 10 -14 24 -44 32 -62 C 31 -40 31 -16 34 -2 C 35 2 38 2 40 0", "M 8 -22 C 16 -26 26 -26 36 -24"] },
B: { w: 38, strokes: [STEM, "M 0 -52 C 12 -66 32 -64 30 -50 C 29 -40 18 -34 12 -32 C 26 -34 36 -26 30 -12 C 24 2 8 4 2 -4"] },
C: { w: 34, strokes: ["M 34 -52 C 30 -66 14 -64 8 -44 C 2 -22 6 4 20 4 C 26 4 30 2 34 0"] },
D: { w: 44, strokes: [STEM, "M 2 -50 C 20 -68 44 -62 42 -36 C 40 -12 20 6 4 0 C 12 2 28 3 44 0"] },
E: { w: 32, strokes: ["M 30 -54 C 24 -64 8 -62 10 -50 C 12 -40 22 -36 24 -34 C 12 -36 2 -26 4 -12 C 6 4 22 6 32 0"] },
F: { w: 34, strokes: [STEM, "M 2 -56 C 12 -64 26 -60 36 -64", "M 4 -30 C 12 -34 20 -32 26 -34"] },
G: { w: 36, strokes: ["M 34 -52 C 30 -66 14 -64 8 -44 C 2 -22 6 4 20 4 C 28 4 32 -10 32 -24 C 26 -28 20 -26 18 -24", "M 32 -24 C 31 -12 32 -2 36 0"] },
H: { w: 42, strokes: [STEM, "M 32 -60 C 32 -40 30 -16 32 -2 C 34 3 38 2 42 0", "M 2 -30 C 14 -34 26 -34 38 -34"] },
I: { w: 24, strokes: [STEM, "M 2 -60 C 8 -62 16 -62 22 -60", "M -2 2 C 4 0 12 0 18 2"] },
J: { w: 26, strokes: ["M 24 -60 C 24 -30 22 0 16 18 C 12 30 0 28 2 14 C 4 6 16 2 26 0", "M 14 -60 C 20 -62 28 -62 34 -60"] },
K: { w: 38, strokes: [STEM, "M 34 -58 C 26 -44 18 -34 11 -28 C 18 -22 24 -2 30 2 C 33 4 36 2 38 0"] },
L: { w: 32, strokes: ["M 26 -52 C 22 -66 10 -62 12 -48 C 14 -30 8 -6 2 0 C 8 -6 18 6 32 0"] },
M: { w: 52, strokes: ["M 2 2 C 6 -18 10 -44 14 -60 C 16 -40 20 -20 24 -8 C 28 -24 34 -44 38 -60 C 38 -40 38 -16 42 -2 C 44 3 48 2 52 0"] },
N: { w: 42, strokes: ["M 2 2 C 6 -18 10 -44 14 -60 C 18 -40 24 -18 30 -2 C 32 -24 33 -44 34 -62"] },
O: { w: 40, strokes: ["M 20 -60 C 4 -60 0 -20 12 -4 C 24 8 38 -14 34 -38 C 32 -54 24 -62 16 -58"] },
P: { w: 34, strokes: [STEM, "M 2 -50 C 14 -66 36 -64 34 -46 C 32 -32 18 -28 10 -30"] },
Q: { w: 42, strokes: ["M 20 -60 C 4 -60 0 -20 12 -4 C 24 8 38 -14 34 -38 C 32 -54 24 -62 16 -58", "M 24 -12 C 30 -6 36 0 42 0"] },
R: { w: 38, strokes: [STEM, "M 2 -50 C 14 -66 36 -64 34 -46 C 32 -32 18 -28 10 -30 C 18 -24 22 -2 30 2 C 33 3 36 2 38 0"] },
S: { w: 34, strokes: ["M 32 -56 C 24 -66 8 -62 10 -48 C 12 -36 28 -32 26 -16 C 24 0 8 6 2 -6 C 10 2 24 4 34 0"] },
T: { w: 36, strokes: ["M 20 -60 C 20 -40 18 -16 12 2", "M 2 -58 C 12 -64 28 -60 38 -64"] },
U: { w: 40, strokes: ["M 6 -60 C 2 -36 2 -2 14 2 C 24 4 30 -20 32 -60 C 30 -36 30 -6 34 -1 C 36 2 38 1 40 0"] },
V: { w: 36, strokes: ["M 6 -60 C 8 -40 12 -12 16 2 C 22 -16 30 -44 36 -62"] },
W: { w: 52, strokes: ["M 4 -60 C 6 -40 10 -12 14 2 C 18 -14 22 -32 26 -44 C 28 -28 32 -10 36 2 C 42 -18 48 -44 52 -62"] },
X: { w: 36, strokes: ["M 6 -60 C 14 -40 22 -16 34 2", "M 34 -60 C 24 -40 12 -16 2 2"] },
Y: { w: 34, strokes: ["M 6 -60 C 6 -44 10 -30 20 -28 C 26 -30 30 -44 32 -60 C 30 -30 24 6 14 28 C 8 38 2 32 6 24 C 12 12 24 4 34 0"] },
Z: { w: 36, strokes: ["M 6 -56 C 14 -62 24 -60 34 -60 C 24 -44 12 -18 2 2 C 12 -2 24 2 36 0", "M 8 -30 C 14 -32 22 -32 28 -32"] },
};
const WORD_GAP = 22;
const LIFT = 26;
const shift = (d: string, x: number) => d.replace(/(-?\d+(?:\.\d+)?) (-?\d+(?:\.\d+)?)/g, (_, a, b) => `${+a + x} ${b}`);
// Turns the name into pen strokes in writing order. Dots and crosses wait for the end of the word, as a hand does.
function layOut(text: string, flourish: boolean) {
const strokes: string[] = [];
let marks: string[] = [];
let run = "";
let x = 0;
const endRun = () => {
if (run) strokes.push(run);
strokes.push(...marks);
run = "";
marks = [];
};
for (const ch of text) {
const big = BIG[ch];
const small = SMALL[ch.toLowerCase()];
if (big) {
endRun();
strokes.push(...big.strokes.map((d) => shift(d, x)));
x += big.w;
} else if (small) {
run = `${run || `M ${x} 0`} ${shift(small.d, x)}`;
marks.push(...(small.marks ?? []).map((d) => shift(d, x)));
x += small.w;
} else {
endRun();
x += WORD_GAP;
}
}
endRun();
if (flourish && x) strokes.push(`M ${x + 2} 2 C ${x * 0.65} 34 ${x * 0.25} 32 -8 26 C ${x * 0.3} 17 ${x * 0.7} 17 ${x + 14} 12`);
return { strokes, width: x };
}
/* SignatureWrite: a pen really signs the name, one continuous line per word, then goes back for the dots and crosses and closes with a swoosh. */
export function SignatureWrite({ text, className = "", duration = 2.8, flourish = true, line = true, once = true }: SignatureWriteProps) {
const reduce = useReducedMotion();
const { strokes, width } = useMemo(() => layOut(text, flourish), [text, flourish]);
const svg = useRef<SVGSVGElement>(null);
const pen = useRef<SVGGElement>(null);
const paths = useRef<(SVGPathElement | null)[]>([]);
const inView = useInView(svg, { once, amount: 0.5 });
const progress = useMotionValue(0);
useEffect(() => {
const done = paths.current.slice(0, strokes.length).filter((p): p is SVGPathElement => !!p);
const lengths = done.map((p) => p.getTotalLength());
const total = lengths.reduce((sum, length) => sum + length + LIFT, 0);
const draw = (p: number) => {
let left = p * total;
let tip: DOMPoint | null = null;
done.forEach((path, i) => {
const length = lengths[i];
const drawn = Math.max(0, Math.min(length, left));
path.style.strokeDasharray = `${length} ${length}`;
path.style.strokeDashoffset = String(length - drawn);
path.style.opacity = drawn > 0 ? "1" : "0";
if (left > 0 && (left < length || !tip)) tip = path.getPointAtLength(drawn);
left -= length + LIFT;
});
if (pen.current && tip) {
const { x, y } = tip as DOMPoint;
pen.current.setAttribute("transform", `translate(${x} ${y})`);
}
pen.current?.setAttribute("opacity", p > 0 && p < 1 ? "1" : "0");
};
const stop = progress.on("change", draw);
if (reduce) progress.set(1);
else progress.set(0);
draw(progress.get());
const signing = inView && !reduce ? animate(progress, 1, { duration, ease: "linear" }) : undefined;
return () => {
stop();
signing?.stop();
};
}, [strokes, inView, reduce, duration, progress]);
return (
<svg
ref={svg}
role="img"
aria-label={text}
viewBox={`-24 -74 ${width + 66} 126`}
className={`h-[2.4em] max-w-full overflow-visible ${className}`}
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
>
{line && (
<g opacity="0.35">
<path d={`M -20 10 H ${width + 40}`} strokeWidth="0.8" strokeDasharray="3 4" />
<path d="M -22 -2 l 6 7 M -16 -2 l -6 7" strokeWidth="1" />
</g>
)}
<g transform="rotate(-2) skewX(-12)">
{strokes.map((d, i) => (
<path
key={i}
ref={(el) => {
paths.current[i] = el;
}}
d={d}
strokeWidth={i === strokes.length - 1 && flourish ? 1.6 : 2.3}
/>
))}
<g ref={pen} opacity="0" stroke="none">
<path d="M0 0 L5 -11 L40 -80 L52 -74 L16 -6 Z" fill="#262631" />
<path d="M0 0 L5 -11 L16 -6 Z" fill="#d9b45b" />
</g>
</g>
</svg>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/signature-write.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/signature-write-demo.tsx
"use client"; import { useState } from "react"; import { SignatureWrite } from "@/components/ui/signature-write"; export default function SignatureWriteExample() { const [run, setRun] = useState(0); return ( <div className="grid h-full place-items-center bg-[#2c2a33] p-6"> <div className="w-full max-w-md rounded bg-[#fbf6ea] px-6 py-5 text-[#1b2a6b] shadow-[0_10px_30px_rgba(0,0,0,0.4)]"> <p className="mb-2 text-xs text-[#6d6657]">Agreed and accepted</p> <SignatureWrite key={run} text="Arun Prakash" className="w-full text-[44px]" once={false} /> <button type="button" onClick={() => setRun((r) => r + 1)} className="mt-2 text-xs text-[#6d6657] hover:text-[#1b2a6b]"> Sign again </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Name to sign. A to Z only: anything else leaves a gap on screen, screen readers still get the full text. |
| duration | number | 2.8 | Seconds for the whole signature. |
| flourish | boolean | true | Add the closing swoosh. |
| line | boolean | true | Show the dotted sign-here line. |
| once | boolean | true | Play only the first time it scrolls into view. |
| className | string | "" | Ink comes from text colour, size from font size. |