Signature Write-on
A handwritten signature writes itself at a pen’s natural pace. The pen keeps one steady speed, so long strokes take longer than short ones, and it lifts for a moment between strokes, with a drop of ink leading the line.
With thanks for a wonderful year,
Elena Marsh, FounderHow to use it
- Paste the snippet just before
</head>. - Add
data-signature-write-onto an<svg>of single-line strokes (astrokeandfill="none"). - Strokes are written in the order they appear, each from its start, so draw them the way you’d write them. Trace a scan of your signature with a pen tool, or use a single-line script font.
Options
Add any of these attributes to the same element.
data-duration | Time for the whole signature. Default 3200 ms. |
data-lift | How long the pen lifts between strokes. Default 140 ms. |
data-pen | A drop of ink leads the line. |
data-delay | Wait before it starts. |
data-loop | Write, hold, fade and write again. |
data-hold | How long it stays, when looping. Default 2400 ms. |
It starts when scrolled into view. Give the SVG a role="img" and an aria-label naming the signer. With reduced motion the signature simply shows.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Signature Write-on: a handwritten signature writes itself at a pen's
// natural pace. The pen moves at a steady speed, so long strokes take
// longer than short ones, and it lifts for a moment between strokes. A nib
// of ink can lead the line. Starts when scrolled into view.
// Usage:
// <svg data-signature-write-on viewBox="0 0 620 110" fill="none"
// stroke="currentColor" stroke-width="2.6" stroke-linecap="round">
// <path d="…first stroke…"/>
// <path d="…next stroke…"/>
// </svg>
// Strokes are written in the order they appear, each from its start, so
// draw them the way you'd write them (from a single-line script font, or
// traced from a scan of your signature).
// Optional: data-duration (ms for the whole signature, default 3200),
// data-lift (ms the pen lifts between strokes, default 140), data-pen (an
// ink nib leads the line), data-delay (ms), data-loop (write, hold, fade
// and write again), data-hold (ms it stays when looping, default 2400).
var DEFAULTS = { duration: 3200, lift: 140, hold: 2400 };
var NS = 'http://www.w3.org/2000/svg';
var SHAPES = 'path, line, polyline, polygon, circle, ellipse, rect';
function setup(svg) {
var strokes = Array.from(svg.querySelectorAll(SHAPES)).filter(function (s) { return !s.closest('defs, clipPath, mask'); });
if (!strokes.length) return null;
var o = { svg: svg, strokes: strokes.map(function (el) { return { el: el, p: 0, len: 0 }; }) };
if (svg.dataset.pen !== undefined && svg.dataset.pen !== 'false') {
var width = parseFloat(getComputedStyle(strokes[0]).strokeWidth) || 2;
o.pen = document.createElementNS(NS, 'circle');
o.pen.setAttribute('r', width * 0.9);
o.pen.setAttribute('aria-hidden', 'true');
o.pen.style.fill = getComputedStyle(strokes[0]).stroke;
o.pen.style.stroke = 'none';
o.pen.style.opacity = 0;
svg.appendChild(o.pen);
}
// Hidden until written; the lengths are measured again when it starts.
o.strokes.forEach(paint);
return o;
}
function paint(s) {
// A zero-length dash with a round cap would still show a dot.
s.el.style.visibility = s.p > 0 ? '' : 'hidden';
s.el.style.strokeDasharray = s.len * s.p + ' 1e5';
}
function run(o) {
var d = o.svg.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var lift = d.lift !== undefined ? +d.lift : DEFAULTS.lift;
var hold = +d.hold || DEFAULTS.hold;
var total = 0;
o.strokes.forEach(function (s) {
s.len = s.el.getTotalLength ? s.el.getTotalLength() : 0;
total += s.len;
});
// Share the time out by length, so the pen keeps one speed.
var writing = Math.max(200, (+d.duration || DEFAULTS.duration) - lift * (o.strokes.length - 1));
var pen = o.pen;
function follow(s) {
if (!pen) return;
var pt = s.el.getPointAtLength(s.len * s.p);
pen.setAttribute('cx', pt.x);
pen.setAttribute('cy', pt.y);
}
var tl;
(function cycle(first) {
o.strokes.forEach(function (s) { s.p = 0; paint(s); });
o.svg.style.opacity = '';
tl = anime.createTimeline({ delay: first ? +d.delay || 0 : 500, onComplete: loop ? function () { cycle(false); } : null });
var at = 0;
o.strokes.forEach(function (s) {
var ms = Math.max(60, (writing * s.len) / (total || 1));
tl.add(s, {
p: [0, 1],
duration: ms,
// A gentle push off and slow down, as a hand does on each stroke.
ease: 'inOut(1.3)',
onBegin: function () { if (pen) pen.style.opacity = 1; },
onUpdate: function () { paint(s); follow(s); }
}, at);
at += ms + lift;
});
if (pen) tl.add(pen, { opacity: 0, duration: 300, ease: 'out(2)' }, at - lift);
if (loop) tl.add(o.svg, { opacity: [1, 0], duration: 700, ease: 'inOut(2)' }, at - lift + hold);
})(true);
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) tl.play(); else tl.pause();
}).observe(o.svg);
}
function init() {
var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
run(e.target._sw);
});
}, { threshold: 0.4 });
document.querySelectorAll('[data-signature-write-on]').forEach(function (svg) {
if (svg._sw || reduced) return;
svg._sw = setup(svg);
if (svg._sw) io.observe(svg);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><svg data-signature-write-on data-pen viewBox="0 0 640 116" fill="none"
stroke="#1e1b4b" stroke-width="2.6" stroke-linecap="round"
role="img" aria-label="Signed: Elena Marsh">
<path d="M78 24 C78 25 80 33 79 32 …"/>
<path d="M94 64 C96 63 102 60 106 55 …"/>
<!-- …one path per stroke… -->
</svg>