Line draw — signed by hand
Variant: Signed by hand
Three strokes with the pen off the paper between them, and the lifts are the whole piece: 638ms of a 5.8s loop spent drawing nothing is the only thing separating handwriting from a plotter tracing a font. The strokes are deliberately not one speed — the body runs at 163 units a second because it is shaped, the cross-bar at 322 because a t-cross is a flick rather than a stroke, and the flourish at 235. Every one of them eases in as well as out, which is the one place in this library where an ease-in half is correct: a hand leaves rest and returns to rest inside each stroke, and the middle stays near constant because nobody coasts to a crawl mid-letter. The printed rule takes the ink’s colour 290ms after the last stroke lands, which is what turns a drawing into a state — the form is signed, not merely written on.
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Line draw — signed by hand</title>
<style>
:root {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
--_muted: color-mix(in srgb, var(--slate) 42%, transparent);
/* Clock — 5.8s: 2.2s of body, a 319ms lift, a 232ms cross, another
319ms lift, an 870ms flourish, then the form sits signed. The two
lifts are 638ms of the loop spent drawing nothing at all, and they
are the only reason this reads as a hand rather than a plotter. */
--cycle: 5.8s;
--t: calc(var(--cycle) / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
/* A gentle symmetric S. The pen leaves rest and returns to rest inside
every stroke, so this is the one place in the library where an
ease-in half is correct — but the middle stays near constant, because
a hand does not coast to a crawl in the middle of a letter. */
--ease-accent: cubic-bezier(0.4, 0.08, 0.6, 0.92);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.sig {
width: min(280px, 88vw);
height: auto;
display: block;
}
/* The printed form is the substrate: it never draws and never resets, so
the loop reads as the same document being signed again. */
.rule {
stroke: var(--oat);
stroke-width: 2;
}
.caption {
fill: var(--_muted);
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.16em;
}
.ink path {
fill: none;
stroke: var(--slate);
stroke-width: 3;
stroke-linecap: round;
stroke-linejoin: round;
/* Offsets run 103 to 0, not 100 to 0: at exactly 100 the dash's round
cap still paints, so a stroke waiting its turn shows as an ink dot
where it is about to begin. Three units parks the cap clear of the
path, and the 200 gap does the same at the far end. */
stroke-dasharray: 100 200;
}
/* pathLength="100" normalises three strokes of very different real
lengths onto one 0-100 scale, so each window below is a share of the
loop rather than a number tuned per path. The speeds that fall out are
163, 322 and 235 units a second — deliberately not equal. */
.stroke-a {
animation: stroke-a var(--t) infinite;
}
.stroke-b {
animation: stroke-b var(--t) infinite;
}
.stroke-c {
animation: stroke-c var(--t) infinite;
}
.signed {
stroke: var(--clay);
stroke-width: 2;
animation: countersign var(--t) infinite;
}
.mark {
animation: mark-hold var(--t) infinite;
}
@keyframes stroke-a {
0%,
2% {
stroke-dashoffset: 103;
animation-timing-function: var(--ease-accent);
}
40%,
96% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 103;
}
}
/* The cross-bar covers a fifth of the body's distance at twice its speed.
A t-cross is a flick, not a stroke — matched to the body's pace it
stops reading as handwriting and starts reading as a printed rule. */
@keyframes stroke-b {
0%,
45.5% {
stroke-dashoffset: 103;
animation-timing-function: var(--ease-accent);
}
49.5%,
96% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 103;
}
}
@keyframes stroke-c {
0%,
55% {
stroke-dashoffset: 103;
animation-timing-function: var(--ease-accent);
}
70%,
96% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: 103;
}
}
/* The rule takes the signature's colour 290ms after the last stroke
lands, which is what turns a drawing into a state: the form is signed,
not merely written on. */
@keyframes countersign {
0%,
70% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
75%,
100% {
opacity: 1;
}
}
/* Everything the hand put there is dark by 96%, which is the only reason
three dashoffsets can rewind to 103 in the last 232ms unseen. */
@keyframes mark-hold {
0%,
90% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
96%,
100% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
/* The draw survives at 1.7x — a signature that appears fully formed is
not a slower signature, it is a stamp — and every clock scales by the
same factor, so the lifts keep their share of the loop. */
.mark,
.stroke-a,
.stroke-b,
.stroke-c,
.signed {
animation-duration: calc(var(--t) * 1.7);
}
}
</style>
</head>
<body>
<svg
class="sig"
viewBox="0 0 260 132"
role="img"
aria-label="A signature writing itself onto a form in three strokes, with the pen lifting between them, after which the signature line takes the ink's colour"
>
<line class="rule" x1="16" y1="116" x2="244" y2="116" />
<text class="caption" x="16" y="130">SIGNATURE</text>
<g class="mark">
<line class="signed" x1="16" y1="116" x2="244" y2="116" />
<g class="ink">
<path
class="stroke-a"
pathLength="100"
d="M 26 88 C 30 58, 42 26, 56 26 C 68 26, 62 50, 52 70 C 44 86, 50 96, 62 88 C 72 81, 80 64, 88 54 C 94 46, 101 49, 99 60 C 97 71, 92 82, 97 86 C 102 90, 112 82, 120 68 C 126 58, 133 52, 137 58 C 141 64, 134 78, 137 84 C 140 90, 150 86, 162 72"
/>
<path class="stroke-b" pathLength="100" d="M 84 44 C 108 38, 134 36, 158 41" />
<path
class="stroke-c"
pathLength="100"
d="M 22 104 C 62 96, 120 94, 168 100 C 196 103, 214 96, 220 84"
/>
</g>
</g>
</svg>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.