← SVG & icons

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, Founder

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-signature-write-on to an <svg> of single-line strokes (a stroke and fill="none").
  3. 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-durationTime for the whole signature. Default 3200 ms.
data-liftHow long the pen lifts between strokes. Default 140 ms.
data-penA drop of ink leads the line.
data-delayWait before it starts.
data-loopWrite, hold, fade and write again.
data-holdHow 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.

1 · Snippet
<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>
2 · Your HTML
<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>