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
- Paste the snippet just before
</head>. - Add an empty element with
data-signature-pad: the pad and a Clear button are built inside it. - 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-color | Ink colour. Default #1d1b4b. |
data-width | The thickest the line gets, in pixels. 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. |
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.
<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">×</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><div data-signature-pad data-name="signature"></div>