Web Animations Medium
Signature Draw
Draw a signature path with pointer input and replay it using stroke-dasharray animation.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78e3c0;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
button, input { font: inherit; }
.demo { width: min(760px, 100%); margin: auto; }
.sig {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 1.75rem);
box-shadow: 0 24px 70px #0006;
}
.sig h1 { margin: 0 0 .35rem; font-size: 1.15rem; letter-spacing: -.01em; }
.sig__hint { margin: 0 0 1.1rem; color: var(--muted); font-size: .85rem; }
kbd {
font: inherit;
font-size: .78rem;
background: #1b2540;
border: 1px solid var(--line);
border-radius: 6px;
padding: 1px 6px;
}
.sig__padwrap {
border: 1px solid var(--line);
border-radius: 16px;
padding: 6px;
background:
radial-gradient(120% 130% at 15% 0%, #78e3c018, transparent 60%);
background-color: #0a1122;
}
.sig__pad {
display: block;
width: 100%;
height: auto;
color: var(--accent);
touch-action: none;
cursor: crosshair;
border-radius: 11px;
outline: none;
}
.sig__pad:focus-visible { box-shadow: 0 0 0 2px var(--accent); }
.sig__baseline { stroke: #2c3854; stroke-width: 1.5; stroke-dasharray: 6 9; }
.sig__caret {
fill: none;
stroke: #7f8fb4;
stroke-width: 1.5;
opacity: 0;
transition: opacity .15s ease;
}
.sig__pad:focus-visible .sig__caret { opacity: 1; }
.sig__caret[data-inking="true"] { stroke: var(--accent); fill: #78e3c040; }
.sig__bar {
display: flex;
flex-wrap: wrap;
gap: .6rem;
align-items: center;
margin-top: 1rem;
}
.btn {
appearance: none;
border: 1px solid var(--line);
background: #18233b;
color: var(--text);
font-size: .85rem;
padding: .55rem .9rem;
border-radius: 10px;
cursor: pointer;
transition: background .15s ease, border-color .15s ease;
}
.btn:hover:not(:disabled) { background: #21304f; border-color: #3a4d78; }
.btn:disabled { opacity: .42; cursor: not-allowed; }
.btn--primary { background: var(--accent); border-color: var(--accent); color: #052a20; font-weight: 600; }
.btn--primary:hover:not(:disabled) { background: #97f0d3; border-color: #97f0d3; }
.field {
display: inline-flex;
align-items: center;
gap: .5rem;
margin-left: auto;
color: var(--muted);
font-size: .74rem;
text-transform: uppercase;
letter-spacing: .07em;
}
.field input[type="range"] { accent-color: var(--accent); width: 120px; }
.sig__status {
margin: .9rem 0 0;
color: var(--muted);
font-size: .8rem;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.sig__caret { transition: none; }
}/* Signature Draw — pointer capture + smoothed SVG paths + stroke-dasharray replay. */
(function () {
const NS = "http://www.w3.org/2000/svg";
const pad = document.getElementById("pad");
const ink = document.getElementById("ink");
const caret = document.getElementById("caret");
const statusEl = document.getElementById("status");
const speedEl = document.getElementById("speed");
const btnReplay = document.getElementById("replay");
const btnUndo = document.getElementById("undo");
const btnClear = document.getElementById("clear");
const VW = 600;
const VH = 260;
const MIN_DIST = 1.6; // viewBox units between recorded samples
/** @type {{points: {x:number,y:number}[], el: SVGPathElement}[]} */
const strokes = [];
let active = null;
let replayRAF = 0;
/* ---------- geometry ---------- */
function toLocal(evt) {
const r = pad.getBoundingClientRect();
return {
x: clamp(((evt.clientX - r.left) / r.width) * VW, 0, VW),
y: clamp(((evt.clientY - r.top) / r.height) * VH, 0, VH),
};
}
const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);
const round = (n) => Math.round(n * 10) / 10;
// Catmull-Rom -> cubic Bezier, giving a smooth handwritten look.
function toPathData(pts) {
if (pts.length === 0) return "";
if (pts.length === 1) {
const p = pts[0];
return `M ${round(p.x)} ${round(p.y)} l 0.01 0`;
}
let d = `M ${round(pts[0].x)} ${round(pts[0].y)}`;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i];
const p1 = pts[i];
const p2 = pts[i + 1];
const p3 = pts[i + 2] || p2;
const c1x = p1.x + (p2.x - p0.x) / 6;
const c1y = p1.y + (p2.y - p0.y) / 6;
const c2x = p2.x - (p3.x - p1.x) / 6;
const c2y = p2.y - (p3.y - p1.y) / 6;
d += ` C ${round(c1x)} ${round(c1y)}, ${round(c2x)} ${round(c2y)}, ${round(p2.x)} ${round(p2.y)}`;
}
return d;
}
/* ---------- stroke lifecycle ---------- */
function beginStroke(pt) {
cancelReplay();
const el = document.createElementNS(NS, "path");
el.setAttribute("d", "");
ink.appendChild(el);
active = { points: [pt], el };
strokes.push(active);
render(active);
}
function extendStroke(pt) {
if (!active) return;
const last = active.points[active.points.length - 1];
if (Math.hypot(pt.x - last.x, pt.y - last.y) < MIN_DIST) return;
active.points.push(pt);
render(active);
}
function endStroke() {
if (!active) return;
if (active.points.length < 2 && active.points.length > 0) {
// keep the dot, it is still ink
}
active = null;
report();
}
function render(stroke) {
stroke.el.setAttribute("d", toPathData(stroke.points));
}
function totalLength() {
return strokes.reduce((sum, s) => sum + s.el.getTotalLength(), 0);
}
function report(msg) {
if (msg) {
statusEl.textContent = msg;
return;
}
if (!strokes.length) {
statusEl.textContent = "Empty signature.";
} else {
const pts = strokes.reduce((n, s) => n + s.points.length, 0);
statusEl.textContent =
`${strokes.length} stroke${strokes.length > 1 ? "s" : ""} · ${pts} points · ` +
`${Math.round(totalLength())} units of path.`;
}
syncButtons();
}
function syncButtons() {
const empty = strokes.length === 0;
btnReplay.disabled = empty;
btnUndo.disabled = empty;
btnClear.disabled = empty;
}
/* ---------- pointer input ---------- */
pad.addEventListener("pointerdown", (e) => {
if (e.button !== 0 && e.pointerType === "mouse") return;
pad.setPointerCapture(e.pointerId);
beginStroke(toLocal(e));
e.preventDefault();
});
pad.addEventListener("pointermove", (e) => {
if (!active) return;
// Coalesced events keep fast strokes faithful on high-rate pointers.
const evts = typeof e.getCoalescedEvents === "function" ? e.getCoalescedEvents() : [e];
for (const ev of evts) extendStroke(toLocal(ev));
});
const stop = (e) => {
if (pad.hasPointerCapture && pad.hasPointerCapture(e.pointerId)) {
pad.releasePointerCapture(e.pointerId);
}
endStroke();
};
pad.addEventListener("pointerup", stop);
pad.addEventListener("pointercancel", stop);
/* ---------- keyboard pen ---------- */
let pen = { x: 300, y: 200 };
let penDown = false;
function moveCaret() {
caret.setAttribute("cx", String(round(pen.x)));
caret.setAttribute("cy", String(round(pen.y)));
caret.setAttribute("data-inking", String(penDown));
}
moveCaret();
pad.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 16 : 5;
let dx = 0;
let dy = 0;
if (e.key === "ArrowLeft") dx = -step;
else if (e.key === "ArrowRight") dx = step;
else if (e.key === "ArrowUp") dy = -step;
else if (e.key === "ArrowDown") dy = step;
else if (e.key === " " || e.key === "Spacebar") {
e.preventDefault();
penDown = !penDown;
if (penDown) beginStroke({ x: pen.x, y: pen.y });
else endStroke();
moveCaret();
report(penDown ? "Pen down — arrows draw." : undefined);
return;
} else if (e.key === "Enter") {
if (penDown) {
penDown = false;
endStroke();
moveCaret();
}
return;
} else {
return;
}
e.preventDefault();
pen = { x: clamp(pen.x + dx, 0, VW), y: clamp(pen.y + dy, 0, VH) };
moveCaret();
if (penDown && active) {
active.points.push({ x: pen.x, y: pen.y });
render(active);
}
});
pad.addEventListener("blur", () => {
if (penDown) {
penDown = false;
endStroke();
moveCaret();
}
});
/* ---------- replay ---------- */
function cancelReplay() {
if (replayRAF) cancelAnimationFrame(replayRAF);
replayRAF = 0;
for (const s of strokes) {
s.el.style.strokeDasharray = "";
s.el.style.strokeDashoffset = "";
s.el.style.opacity = "";
}
}
function replay() {
cancelReplay();
if (!strokes.length) return;
const lens = strokes.map((s) => s.el.getTotalLength());
const total = lens.reduce((a, b) => a + b, 0) || 1;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const duration = reduced ? 0 : Number(speedEl.value);
strokes.forEach((s, i) => {
s.el.style.strokeDasharray = `${lens[i]} ${lens[i]}`;
s.el.style.strokeDashoffset = String(lens[i]);
});
if (duration === 0) {
strokes.forEach((s) => (s.el.style.strokeDashoffset = "0"));
report("Replay complete (reduced motion: instant).");
return;
}
const start = performance.now();
const tick = (now) => {
// Single clock drives every stroke in recorded order, proportional to length.
const drawn = clamp((now - start) / duration, 0, 1) * total;
let acc = 0;
strokes.forEach((s, i) => {
const local = clamp(drawn - acc, 0, lens[i]);
s.el.style.strokeDashoffset = String(lens[i] - local);
acc += lens[i];
});
if (now - start < duration) {
replayRAF = requestAnimationFrame(tick);
} else {
replayRAF = 0;
report("Replay complete.");
}
};
replayRAF = requestAnimationFrame(tick);
report("Replaying…");
}
/* ---------- controls ---------- */
btnReplay.addEventListener("click", replay);
btnUndo.addEventListener("click", () => {
cancelReplay();
const s = strokes.pop();
if (s) s.el.remove();
active = null;
penDown = false;
moveCaret();
report();
pad.focus();
});
btnClear.addEventListener("click", () => {
cancelReplay();
strokes.length = 0;
active = null;
penDown = false;
ink.textContent = "";
moveCaret();
report();
pad.focus();
});
report();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Signature Draw</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Signature Draw">
<section class="sig" aria-labelledby="sig-title">
<header>
<h1 id="sig-title">Signature Draw</h1>
<p class="sig__hint">
Sign with mouse, touch or pen. Keyboard: arrow keys move the pen,
<kbd>Space</kbd> lifts or lowers it, <kbd>Enter</kbd> ends the stroke.
</p>
</header>
<div class="sig__padwrap">
<svg
class="sig__pad"
id="pad"
viewBox="0 0 600 260"
role="application"
tabindex="0"
aria-label="Signature area. Arrow keys move the pen, Space toggles ink."
preserveAspectRatio="xMidYMid meet"
>
<line class="sig__baseline" x1="48" y1="206" x2="552" y2="206" />
<g id="ink" fill="none" stroke="currentColor" stroke-width="3.5"
stroke-linecap="round" stroke-linejoin="round"></g>
<circle id="caret" class="sig__caret" cx="300" cy="200" r="6" />
</svg>
</div>
<div class="sig__bar">
<button type="button" id="replay" class="btn btn--primary">Replay</button>
<button type="button" id="undo" class="btn">Undo stroke</button>
<button type="button" id="clear" class="btn">Clear</button>
<label class="field" for="speed">
<span>Speed</span>
<input type="range" id="speed" min="300" max="4000" step="100" value="1500" />
</label>
</div>
<p class="sig__status" id="status" role="status" aria-live="polite">Empty signature.</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgSignatureDraw() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Signature Draw</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Signature Draw
Draw a signature path with pointer input and replay it using stroke-dasharray animation.
Support notes
SVG animation can be driven by SMIL, CSS, or JavaScript. Keep path point counts compatible for reliable morphing and provide a non-animated state for reduced motion.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.