"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 = { 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 = { 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(null); const pen = useRef(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 ( {line && ( )} {strokes.map((d, i) => ( { paths.current[i] = el; }} d={d} strokeWidth={i === strokes.length - 1 && flourish ? 1.6 : 2.3} /> ))} ); }