← Forms

Signature Pad

Sign with a mouse, finger or pen. The ink thins as you speed up and thickens as you slow down, like a real pen, and the slightly wobbly line just behind the tip relaxes into a smooth curve as you write. Clear fades the signature away.

How to use it

  1. Paste the snippet just before </head>.
  2. Add an empty element with data-signature-pad: the pad and a Clear button are built inside it.
  3. It fills the element’s width. Set the pad’s height in CSS with .sgp-pad { height: 12em } if the default 11em doesn’t suit.

Options

Add any of these attributes to the same element.

data-colorInk colour. Default #1d1b4b.
data-widthThe thickest the line gets, in pixels. Default 3.
data-hintText on the empty pad. Default Sign here.
data-nameAdds a hidden input with this name holding the signature as a PNG data URL, so it posts with a form.
data-autoplayIt signs by itself, clears and signs again while in view. It stops as soon as you draw.

Strokes are stored as fractions of the pad’s width, so the signature redraws crisply when the window is resized. Listen for the sign event: e.detail.dataURL is the signature as a PNG, and e.detail.empty is true after clearing.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Signature Pad: sign with a mouse, finger or pen. The ink thins as you
    // move faster, like a real pen, and the wobbly line just behind the tip
    // relaxes into a smooth curve as you write.
    // Usage: <div data-signature-pad></div>
    //   The pad fills the element's width; give it a height (default 11em).
    //   A Clear button is added underneath.
    // Optional: data-color (ink colour, default #1d1b4b), data-width (thickest
    // line in px, default 3), data-hint (text on the empty pad, default "Sign
    // here"), data-name (adds a hidden input with this name holding the
    // signature as a PNG data URL, so it posts with a form), data-autoplay (it
    // signs by itself, clears and signs again while in view; it stops as soon
    // as you draw).
    // Listen for the "sign" event: e.detail.dataURL is the signature as a PNG,
    // e.detail.empty is true after clearing.
    var DEFAULTS = { color: '#1d1b4b', width: 3, hint: 'Sign here' };

    var css =
      '.sgp{position:relative}' +
      '.sgp-pad{position:relative;height:11em;border-radius:1em;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.08),0 10px 30px rgba(15,15,30,.07);overflow:hidden}' +
      '.sgp-canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;cursor:crosshair}' +
      '.sgp-line{position:absolute;left:8%;right:8%;bottom:26%;height:0;border-top:1.5px dashed #d4d4d8;pointer-events:none}' +
      '.sgp-x{position:absolute;left:8%;bottom:calc(26% + .25em);font-size:1.1em;line-height:1;color:#a1a1aa;pointer-events:none}' +
      '.sgp-hint{position:absolute;left:0;right:0;bottom:calc(26% - 1.9em);text-align:center;font-size:.8em;letter-spacing:.06em;text-transform:uppercase;color:#a1a1aa;pointer-events:none;transition:opacity .35s,transform .35s}' +
      '.sgp-inked .sgp-hint{opacity:0;transform:translateY(.4em)}' +
      '.sgp-nib{position:absolute;left:0;top:0;width:.7em;height:.7em;margin:-.35em 0 0 -.35em;border-radius:50%;background:var(--sgp-color);box-shadow:0 0 0 .35em color-mix(in srgb,var(--sgp-color) 15%,transparent);opacity:0;pointer-events:none;transition:opacity .25s}' +
      '.sgp-bar{display:flex;justify-content:flex-end;margin-top:.7em}' +
      '.sgp-clear{padding:.45em 1em;border:1px solid #dcdce1;border-radius:2em;background:#fff;color:#3f3f46;font:inherit;font-size:.85em;font-weight:600;cursor:pointer;transition:opacity .25s}' +
      '.sgp:not(.sgp-inked) .sgp-clear{opacity:.4;cursor:default}' +
      '.sgp-clear:focus-visible{outline:2px solid var(--sgp-color);outline-offset:2px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function on(v) { return v !== undefined && v !== 'false'; }

    // A made-up signature in a unit box: looping cursive, then an underline.
    function signature() {
      var a = [], b = [], th, r = 1;
      for (th = -0.6; th <= Math.PI * 11.2; th += 0.09) {
        var dd = r * (1.25 + 1.1 * Math.abs(Math.sin(th * 0.23 + 0.4)));
        var tall = th < Math.PI * 2 ? 1.7 : 1;
        a.push([r * th * 1.15 - dd * Math.sin(th), (r - dd * Math.cos(th)) * tall]);
      }
      for (var i = 0; i <= 50; i++) {
        var t = i / 50;
        b.push([0.05 + t * 0.95, 0.08 * Math.sin(t * Math.PI * 0.9) - (t > 0.85 ? (t - 0.85) * 1.3 : 0)]);
      }
      // Fit the loops into x 0.1..0.88, y 0.16..0.7 of a pad as wide as 1 unit.
      var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
      a.forEach(function (p) { minX = Math.min(minX, p[0]); maxX = Math.max(maxX, p[0]); minY = Math.min(minY, p[1]); maxY = Math.max(maxY, p[1]); });
      a = a.map(function (p) { return [0.1 + (p[0] - minX) / (maxX - minX) * 0.78, 0.16 + (p[1] - minY) / (maxY - minY) * 0.54]; });
      b = b.map(function (p) { return [0.08 + p[0] * 0.8, 0.8 - p[1]]; });
      return [a, b];
    }

    function setup(el) {
      var d = el.dataset;
      var mock = !!el.closest('[aria-hidden="true"]');
      var color = d.color || DEFAULTS.color;
      var maxW = parseFloat(d.width) || DEFAULTS.width, minW = maxW * 0.4;
      el.classList.add('sgp');
      el.style.setProperty('--sgp-color', color);
      el.textContent = '';

      var pad = document.createElement('div');
      pad.className = 'sgp-pad';
      pad.innerHTML = '<span class="sgp-line"></span><span class="sgp-x">&times;</span><span class="sgp-hint"></span><canvas class="sgp-canvas"></canvas><span class="sgp-nib"></span>';
      el.appendChild(pad);
      pad.querySelector('.sgp-hint').textContent = d.hint || DEFAULTS.hint;
      pad.querySelectorAll('span').forEach(function (s) { s.setAttribute('aria-hidden', 'true'); });
      var canvas = pad.querySelector('canvas');
      var nib = pad.querySelector('.sgp-nib');
      var ctx = canvas.getContext('2d');
      if (!mock) {
        canvas.setAttribute('role', 'img');
        canvas.setAttribute('aria-label', 'Signature pad: draw your signature here');
        var bar = document.createElement('div');
        bar.className = 'sgp-bar';
        var clearBtn = document.createElement('button');
        clearBtn.type = 'button';
        clearBtn.className = 'sgp-clear';
        clearBtn.textContent = 'Clear';
        bar.appendChild(clearBtn);
        el.appendChild(bar);
        clearBtn.addEventListener('click', function () { stop(); clear(true); });
      } else canvas.setAttribute('aria-hidden', 'true');
      var hidden = null;
      if (!mock && d.name) {
        hidden = document.createElement('input');
        hidden.type = 'hidden';
        hidden.name = d.name;
        el.appendChild(hidden);
      }

      // Points are stored as fractions of the pad's width, so resizing keeps them.
      var strokes = [], W = 0, H = 0, dpr = 1;
      function size() {
        W = pad.clientWidth; H = pad.clientHeight;
        dpr = Math.min(2, window.devicePixelRatio || 1);
        canvas.width = Math.round(W * dpr);
        canvas.height = Math.round(H * dpr);
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        draw();
      }

      var WIN = 3, SETTLE = 260;
      // Each point keeps its raw position (x, y) and eases (k: 0 to 1) to the
      // average of its neighbours once they exist, or once the stroke ends.
      function settle(s, dt) {
        var n = s.pts.length, busy = false;
        for (var i = 0; i < n; i++) {
          var p = s.pts[i];
          if (p.k >= 1) continue;
          if (!s.done && i > n - 1 - WIN) { busy = true; continue; }
          if (p.sx === undefined) {
            var sx = 0, sy = 0, sw = 0, c = 0;
            for (var j = Math.max(0, i - WIN); j <= Math.min(n - 1, i + WIN); j++) { sx += s.pts[j].x; sy += s.pts[j].y; sw += s.pts[j].w; c++; }
            p.sx = sx / c; p.sy = sy / c; p.sw = sw / c;
            if (i === 0 || i === n - 1) { p.sx = p.x; p.sy = p.y; }
          }
          p.k = reduced() ? 1 : Math.min(1, p.k + dt / SETTLE);
          busy = true;
        }
        return busy;
      }
      function pos(p) {
        if (p.sx === undefined) return [p.x * W, p.y * W, p.w];
        var e = 1 - Math.pow(1 - p.k, 3);
        return [(p.x + (p.sx - p.x) * e) * W, (p.y + (p.sy - p.y) * e) * W, p.w + (p.sw - p.w) * e];
      }
      function draw() {
        ctx.clearRect(0, 0, W, H);
        ctx.strokeStyle = color;
        ctx.fillStyle = color;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        var scale = Math.max(0.6, Math.min(1.4, W / 360));
        strokes.forEach(function (s) {
          var q = s.pts.map(pos);
          if (q.length === 1) {
            ctx.beginPath();
            ctx.arc(q[0][0], q[0][1], q[0][2] * scale / 2, 0, Math.PI * 2);
            ctx.fill();
            return;
          }
          // Quadratic curves through the midpoints, each with its own width.
          var mx = q[0][0], my = q[0][1];
          for (var i = 1; i < q.length; i++) {
            var nx = i < q.length - 1 ? (q[i][0] + q[i + 1][0]) / 2 : q[i][0];
            var ny = i < q.length - 1 ? (q[i][1] + q[i + 1][1]) / 2 : q[i][1];
            ctx.beginPath();
            ctx.moveTo(mx, my);
            ctx.quadraticCurveTo(q[i][0], q[i][1], nx, ny);
            ctx.lineWidth = q[i][2] * scale;
            ctx.stroke();
            mx = nx; my = ny;
          }
        });
      }

      var raf = 0, last = 0;
      function loop(now) {
        var dt = last ? Math.min(64, now - last) : 16;
        last = now;
        var busy = false;
        strokes.forEach(function (s) { if (settle(s, dt)) busy = true; });
        draw();
        raf = busy || drawing ? requestAnimationFrame(loop) : 0;
        if (!raf) last = 0;
      }
      function kick() { if (!raf) raf = requestAnimationFrame(loop); }

      var drawing = null;
      function begin(x, y, t) {
        drawing = { pts: [], done: false, lt: t, lw: maxW };
        strokes.push(drawing);
        el.classList.add('sgp-inked');
        add(x, y, t);
      }
      function add(x, y, t) {
        var s = drawing, prev = s.pts[s.pts.length - 1];
        var w = maxW * 0.75;
        if (prev) {
          var dist = Math.hypot(x - prev.x, y - prev.y) * W, dtm = Math.max(1, t - s.lt);
          if (dist < 0.8) return;
          var target = Math.max(minW, Math.min(maxW, maxW * 1.15 - (dist / dtm) * maxW * 0.55));
          w = s.lw + (target - s.lw) * 0.35;
        }
        s.lw = w; s.lt = t;
        s.pts.push({ x: x, y: y, w: w, k: 0 });
        kick();
      }
      function end() {
        if (!drawing) return;
        drawing.done = true;
        drawing = null;
        kick();
        if (!mock) setTimeout(emit, SETTLE + 50);
      }
      function emit() {
        var url = strokes.length ? canvas.toDataURL('image/png') : '';
        if (hidden) hidden.value = url;
        el.dispatchEvent(new CustomEvent('sign', { detail: { dataURL: url, empty: !strokes.length }, bubbles: true }));
      }
      function clear(animate, then) {
        if (!strokes.length) { if (then) then(); return; }
        var done = function () {
          strokes = []; drawing = null;
          canvas.style.opacity = '';
          el.classList.remove('sgp-inked');
          draw();
          if (!mock) emit();
          if (then) then();
        };
        if (!animate || reduced()) { done(); return; }
        anime.animate(canvas, { opacity: [1, 0], translateY: [0, 6], duration: 380, ease: 'in(2)', onComplete: function () { anime.utils.set(canvas, { translateY: 0 }); done(); } });
      }

      // Real drawing.
      function local(e) {
        var r = canvas.getBoundingClientRect();
        return [(e.clientX - r.left) / W, (e.clientY - r.top) / W];
      }
      if (!mock) {
        canvas.addEventListener('pointerdown', function (e) {
          if (e.button > 0) return;
          e.preventDefault();
          stop();
          canvas.setPointerCapture(e.pointerId);
          var p = local(e);
          begin(p[0], p[1], e.timeStamp);
        });
        canvas.addEventListener('pointermove', function (e) {
          if (!drawing || !canvas.hasPointerCapture(e.pointerId)) return;
          var list = e.getCoalescedEvents ? e.getCoalescedEvents() : [e];
          if (!list.length) list = [e];
          list.forEach(function (ev) { var p = local(ev); add(p[0], p[1], ev.timeStamp); });
        });
        canvas.addEventListener('pointerup', end);
        canvas.addEventListener('pointercancel', end);
      }

      size();
      if (window.ResizeObserver) new ResizeObserver(size).observe(pad);

      // Autoplay: a pretend pen signs, holds, clears and signs again.
      var stopped = false, timer = 0, visible = false, auto = on(d.autoplay);
      var sig = signature();
      function stop() {
        if (stopped) return;
        stopped = true;
        clearTimeout(timer);
        nib.style.opacity = '0';
        if (auto) { strokes = []; drawing = null; el.classList.remove('sgp-inked'); draw(); }
      }
      function playStroke(si, then) {
        var pts = sig[si], i = 0, t = performance.now(), aspect = H / W;
        function pt(k) {
          // A touch of hand wobble, which the smoothing then irons out.
          return [pts[k][0] + (Math.random() - 0.5) * 0.006, (pts[k][1] + (Math.random() - 0.5) * 0.012) * aspect];
        }
        var p0 = pt(0);
        begin(p0[0], p0[1], t);
        nib.style.opacity = '1';
        (function step() {
          if (stopped) return;
          var per = si === 0 ? 3 : 2;
          for (var n = 0; n < per && i < pts.length - 1; n++) {
            i++;
            t += 8 + Math.random() * 4 + (si === 0 ? Math.abs(Math.sin(i * 0.17)) * 10 : 0);
            var p = pt(i);
            add(p[0], p[1], t);
            nib.style.transform = 'translate(' + (p[0] * W).toFixed(1) + 'px,' + (p[1] * W).toFixed(1) + 'px)';
          }
          if (i < pts.length - 1) timer = setTimeout(step, 16);
          else { end(); nib.style.opacity = '0'; timer = setTimeout(then, 260); }
        })();
      }
      function cycle() {
        if (stopped || !visible) return;
        playStroke(0, function () {
          playStroke(1, function () {
            timer = setTimeout(function () { clear(true, function () { timer = setTimeout(cycle, 600); }); }, 1800);
          });
        });
      }
      function still() {
        // Reduced motion: show a finished signature.
        var aspect = H / W;
        sig.forEach(function (pts) {
          begin(pts[0][0], pts[0][1] * aspect, 0);
          for (var k = 1; k < pts.length; k++) add(pts[k][0], pts[k][1] * aspect, k * 12);
          end();
        });
      }
      if (auto) {
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          clearTimeout(timer);
          if (stopped) return;
          if (reduced()) { if (visible && !strokes.length) still(); return; }
          if (visible) {
            strokes = []; drawing = null;
            el.classList.remove('sgp-inked');
            nib.style.opacity = '0';
            draw();
            timer = setTimeout(cycle, 500);
          }
        }).observe(el);
      }
    }

    function init() {
      document.querySelectorAll('[data-signature-pad]').forEach(function (el) {
        if (el._sgp) return;
        el._sgp = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-signature-pad data-name="signature"></div>