Handwritten word
One word in a headline is written by a pen stroke, then underlined with a quick hand drawn swoosh.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
// "human" as one hand authored monoline stroke, written left to right like a pen would.
const WORD =
"M4 88C18 82 33 54 37 32C40 16 31 10 28 22C25 38 21 66 18 90" +
"C22 72 31 60 39 60C47 60 47 72 45 82C44 90 51 92 57 86" +
"C61 78 63 68 64 61C62 72 60 88 68 90C76 91 82 76 84 61C83 74 83 88 91 89" +
"C97 81 100 67 103 61C104 70 103 80 102 90C104 72 112 60 119 61C126 62 124 78 122 90" +
"C125 72 133 60 140 61C147 62 145 78 144 85C143 92 150 92 156 86" +
"C162 74 174 58 184 64C172 56 158 66 159 80C160 92 176 90 182 72C183 67 184 63 185 60C183 72 181 88 190 89" +
"C195 80 197 68 199 61C199 72 199 82 198 90C201 72 210 60 217 61C224 62 222 78 221 85C220 92 230 92 242 80";
const SWOOSH = "M10 105C62 99 150 94 240 96C226 98 214 100 204 103";
const PLAY = 2100; // ms for the word and the underline together
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [run, setRun] = useState(0);
const lastRun = useRef(0);
const replay = () => {
if (performance.now() - lastRun.current < PLAY) return;
lastRun.current = performance.now();
setRun((r) => r + 1);
};
// Each time the headline scrolls back into view, the word is written again.
useEffect(() => {
if (inView) replay();
}, [inView]);
const onEnter = (e: PointerEvent) => {
if (e.pointerType !== "touch") replay();
};
return (
<div ref={root} className={styles.root} data-play={run > 0 && !reduced ? "" : undefined} onPointerEnter={onEnter} onClick={replay}>
<h2 className={styles.title}>
Replies that sound{" "}
<span className={styles.word}>
<span className={styles.sr}>human</span>
<svg key={run} viewBox="0 0 250 112" aria-hidden="true">
<path className={styles.ink} d={WORD} pathLength={1} />
<path className={styles.swoosh} d={SWOOSH} pathLength={1} />
</svg>
</span>
</h2>
<p className={styles.sub}>Drafts in your voice, ready before you open the thread.</p>
<button type="button" className={styles.replay} onClick={(e) => { e.stopPropagation(); lastRun.current = 0; replay(); }}>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M3 13l1-3.5L11.5 2a1.4 1.4 0 0 1 2 2L6 11.5z" /></svg>
Write it again
</button>
</div>
);
}
demo.module.css
.root {
container-type: size; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
width: 100%; height: 100%; padding: 20px; box-sizing: border-box; background: var(--surface); text-align: center;
}
.title { margin: 0; font-size: clamp(28px, 7.5cqi, 54px); font-weight: 700; letter-spacing: -0.03em; line-height: 1.15; color: var(--ink); }
.sub { margin: 0; max-width: 34ch; font-size: clamp(13px, 2.4cqi, 16px); color: var(--muted); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
/* The word sits on the text baseline: y 90 of the 112 unit box is the baseline, so the box drops 22/112 of its height. */
.word { display: inline-block; white-space: nowrap; }
.word svg { display: inline-block; height: 1.7em; width: auto; aspect-ratio: 250 / 112; vertical-align: calc(-1.7em * 22 / 112); overflow: visible; }
.ink, .swoosh { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; }
.ink { stroke: var(--accent-text); stroke-width: 5.5; }
.swoosh { stroke: var(--accent); stroke-width: 4; stroke-dashoffset: 0; }
/* Pen speed: steady through the word, a little ease at the start and the lift off. */
.root[data-play] .ink { animation: write 1.5s cubic-bezier(.45, .05, .55, .95) both; }
.root[data-play] .swoosh { animation: write .45s var(--out) 1.55s both; }
@keyframes write { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
.replay {
display: inline-flex; align-items: center; gap: 6px; min-height: 36px; padding: 0 14px 0 12px; border-radius: 999px;
border: 1px solid var(--line); background: var(--white); color: var(--ink); font: inherit; font-size: 13px; font-weight: 650;
cursor: pointer; transition: border-color .2s, transform .18s var(--out);
}
.replay svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }
.replay:hover { border-color: var(--accent-soft); }
.replay:active { transform: scale(.96); }
.replay:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
@container (max-height: 270px) {
.root { gap: 8px; }
.sub { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.root[data-play] .ink, .root[data-play] .swoosh { animation: none; }
}
Use it for
- Hero headlines that need one warm, human word
- Products about writing, notes, mail or signatures
- Marking the single idea a section is about
Avoid
- More than one handwritten word per view
- Body copy or any text people need to read fast
How it works
The word is a single hand authored SVG path drawn as one monoline stroke, so it reads like a pen rather than an outlined font. With pathLength set to 1, a CSS keyframe takes stroke-dashoffset from 1 to 0 at an even pen speed, and the underline path runs the same keyframe once the word is done. Changing the SVG key remounts it to replay, which happens on scroll into view, on mouse hover and on tap.
Accessibility
The SVG is aria-hidden and a visually hidden span keeps the word in the heading text. Write it again is a real button for keyboard users. Reduced motion shows the finished word and underline with no drawing.