Pattern Lock
A grid of dots you join with your finger to draw an unlock pattern. Each dot pops as the line reaches it, and the line stretches to follow your finger. Let go and it checks: green if it matches, a red shake if not, then it clears for another go.
Draw a Z to unlock
How to use it
- Paste the snippet just before
</head>. - Add
data-pattern-lockto an empty element with a width. The script draws the dots. - Set the answer with
data-pattern, numbering the dots from 1, left to right and top to bottom.
Options
Add any of these attributes to the same element.
data-pattern | The right pattern, such as 1-2-3-5-7-8-9. Leave it out to accept any pattern of 4 or more dots. |
data-grid | Dots per side. Default 3. |
data-status | Adds a line of text underneath saying what happened. |
data-autoplay | A pretend finger draws a wrong pattern, then the right one, every so many ms while in view. Default 1600. A real touch pauses it. |
Like on a phone, passing straight over a dot joins it too. It fires pattern with detail.pattern and detail.ok. Set color for the dots, and --ptn-ok and --ptn-bad for the result colours. Keep a real password as well: a pattern is easy to watch over a shoulder.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Pattern Lock: a grid of dots you join with your finger to draw an unlock
// pattern. Each dot pops as the line reaches it and the line stretches to
// follow your finger. Let go and it checks the pattern: green if it
// matches, a red shake if it doesn't, then it clears for another go.
// Usage: <div data-pattern-lock data-pattern="1-2-3-5-7-8-9"></div>
// Dots are numbered from 1, left to right, top to bottom.
// Optional: data-pattern (the right answer; leave it out to accept any
// pattern of 4 or more dots), data-grid (dots per side, default 3),
// data-status (adds a line of text underneath saying what happened),
// data-autoplay (a pretend finger draws a wrong pattern, then the right
// one, every so many ms while in view, default 1600; a real touch pauses it).
// Colours: set color on the element for the dots; --ptn-ok and --ptn-bad
// for the result colours.
// Events: "pattern" with detail.pattern ("1-2-3…") and detail.ok.
var DEFAULTS = { grid: 3, autoplay: 1600, min: 4 };
var css =
'.ptn{--ptn-ok:#22c55e;--ptn-bad:#f43f5e;display:flex;flex-direction:column;align-items:center;gap:10px}' +
'.ptn-svg{display:block;width:100%;height:auto;aspect-ratio:1;touch-action:none;user-select:none;-webkit-user-select:none;overflow:visible;cursor:crosshair}' +
'.ptn-ring,.ptn-dot{transform-box:fill-box;transform-origin:center}' +
'.ptn-ring{fill:currentColor;fill-opacity:0;stroke:currentColor;stroke-opacity:.18;stroke-width:2}' +
'.ptn-dot{fill:currentColor}' +
'.ptn-line{fill:none;stroke:currentColor;stroke-opacity:.55;stroke-width:9;stroke-linecap:round;stroke-linejoin:round}' +
'.ptn-ok .ptn-line,.ptn-ok .ptn-on .ptn-dot,.ptn-ok .ptn-on .ptn-ring{stroke:var(--ptn-ok);fill:var(--ptn-ok)}' +
'.ptn-bad .ptn-line,.ptn-bad .ptn-on .ptn-dot,.ptn-bad .ptn-on .ptn-ring{stroke:var(--ptn-bad);fill:var(--ptn-bad)}' +
'.ptn-ok .ptn-line,.ptn-bad .ptn-line{fill:none;stroke-opacity:.8}' +
'.ptn-on .ptn-ring{fill-opacity:.14;stroke-opacity:.5}' +
'.ptn-finger{fill:rgba(255,255,255,.3);stroke:rgba(255,255,255,.9);stroke-width:3;opacity:0;pointer-events:none}' +
'.ptn-status{margin:0;min-height:1.3em;font:600 14px/1.3 system-ui,sans-serif;opacity:.85;text-align:center}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
var NS = 'http://www.w3.org/2000/svg';
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function gcd(a, b) { return b ? gcd(b, a % b) : Math.abs(a); }
function setup(el) {
var d = el.dataset;
var n = Math.max(2, Math.min(6, +d.grid || DEFAULTS.grid));
var answer = d.pattern ? d.pattern.split(/[^0-9]+/).filter(Boolean).map(Number).join('-') : '';
var thumb = !!el.closest('[aria-hidden="true"]');
el.classList.add('ptn');
var V = 300, gap = V / n;
var svg = document.createElementNS(NS, 'svg');
svg.setAttribute('class', 'ptn-svg');
svg.setAttribute('viewBox', '0 0 ' + V + ' ' + V);
if (!thumb) { svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', 'Pattern lock: join the dots to unlock'); }
else svg.setAttribute('aria-hidden', 'true');
var line = document.createElementNS(NS, 'polyline');
line.setAttribute('class', 'ptn-line');
svg.appendChild(line);
var dots = [];
for (var i = 0; i < n * n; i++) {
var g = document.createElementNS(NS, 'g');
var cx = gap * (i % n + 0.5), cy = gap * (Math.floor(i / n) + 0.5);
g.setAttribute('transform', 'translate(' + cx + ' ' + cy + ')');
g.innerHTML = '<circle class="ptn-ring" r="' + gap * 0.27 + '"/><circle class="ptn-dot" r="' + gap * 0.075 + '"/>';
svg.appendChild(g);
dots.push({ g: g, x: cx, y: cy, ring: g.firstChild, dot: g.lastChild, col: i % n, row: Math.floor(i / n) });
}
var finger = document.createElementNS(NS, 'circle');
finger.setAttribute('class', 'ptn-finger');
finger.setAttribute('r', '18');
svg.appendChild(finger);
el.appendChild(svg);
var status = null;
if (d.status !== undefined && !thumb) {
status = document.createElement('p');
status.className = 'ptn-status';
status.setAttribute('aria-live', 'polite');
status.textContent = 'Draw your pattern';
el.appendChild(status);
}
var s = el._ptn = { path: [], drawing: false, busy: false, userAt: -1e9 };
var tip = null, clearTimer = 0;
function render() {
var pts = s.path.map(function (k) { return dots[k].x + ',' + dots[k].y; });
if (s.drawing && tip && s.path.length) pts.push(tip.x + ',' + tip.y);
line.setAttribute('points', pts.join(' '));
}
function light(k) {
s.path.push(k);
dots[k].g.classList.add('ptn-on');
if (!reduced()) {
anime.animate(dots[k].dot, { scale: [1, 2.1, 1.5], duration: 420, ease: 'out(3)' });
anime.animate(dots[k].ring, { scale: [0.7, 1.12, 1], duration: 480, ease: 'outBack(2)' });
} else {
anime.utils.set(dots[k].dot, { scale: 1.5 });
}
if (navigator.vibrate && !thumb && s.userAt > performance.now() - 50) try { navigator.vibrate(6); } catch (e) {}
}
// Join the dot under the finger, and any dot it jumped straight over.
function hit(x, y) {
for (var k = 0; k < dots.length; k++) {
if (s.path.indexOf(k) > -1) continue;
if (Math.hypot(dots[k].x - x, dots[k].y - y) > gap * 0.3) continue;
var last = s.path[s.path.length - 1];
if (last !== undefined) {
var a = dots[last], b = dots[k];
var dc = b.col - a.col, dr = b.row - a.row, m = gcd(dc, dr);
for (var j = 1; j < m; j++) {
var mid = (a.row + dr / m * j) * n + a.col + dc / m * j;
if (s.path.indexOf(mid) < 0) light(mid);
}
}
light(k);
return;
}
}
function clear(ms) {
clearTimeout(clearTimer);
clearTimer = setTimeout(function () {
anime.animate([line].concat(dots.map(function (o) { return o.dot; })), {
opacity: [1, 0], duration: reduced() ? 0 : 220, ease: 'out(2)',
onComplete: function () {
s.path = [];
s.busy = false;
el.classList.remove('ptn-ok', 'ptn-bad');
dots.forEach(function (o) {
o.g.classList.remove('ptn-on');
anime.utils.set(o.dot, { scale: 1 });
anime.utils.set(o.ring, { scale: 1 });
});
render();
anime.animate([line].concat(dots.map(function (o) { return o.dot; })), { opacity: 1, duration: reduced() ? 0 : 260 });
if (status) status.textContent = 'Draw your pattern';
},
});
}, ms);
}
function begin(x, y) {
if (s.busy) return false;
clearTimeout(clearTimer);
s.drawing = true;
tip = { x: x, y: y };
hit(x, y);
render();
return true;
}
function moveTo(x, y) {
if (!s.drawing) return;
tip = { x: x, y: y };
hit(x, y);
render();
}
function finish() {
if (!s.drawing) return;
s.drawing = false;
render();
var code = s.path.map(function (k) { return k + 1; }).join('-');
if (s.path.length < 2) { clear(0); return; }
s.busy = true;
var ok = answer ? code === answer : s.path.length >= DEFAULTS.min;
el.classList.add(ok ? 'ptn-ok' : 'ptn-bad');
if (status) status.textContent = ok ? 'Unlocked' : (answer ? 'Wrong pattern, try again' : 'Join at least ' + DEFAULTS.min + ' dots');
if (ok) {
anime.animate(dots.filter(function (o, k) { return s.path.indexOf(k) > -1; }).map(function (o) { return o.ring; }), {
scale: [1, 1.3, 1], duration: 500, delay: anime.stagger(40), ease: 'out(3)',
});
} else if (!reduced()) {
anime.animate(svg, { translateX: [0, -10, 9, -6, 4, 0], duration: 420, ease: 'linear' });
}
el.dispatchEvent(new CustomEvent('pattern', { detail: { pattern: code, ok: ok } }));
clear(ok ? 1100 : 800);
}
function local(e) {
var r = svg.getBoundingClientRect();
return { x: (e.clientX - r.left) / r.width * V, y: (e.clientY - r.top) / r.height * V };
}
if (!thumb) {
var pid = null;
svg.addEventListener('pointerdown', function (e) {
if (e.button) return;
s.userAt = performance.now();
if (auto) { auto.pause(); auto = null; anime.animate(finger, { opacity: 0, duration: 150 }); if (s.drawing) { s.drawing = false; s.busy = false; clear(0); return; } }
var p = local(e);
if (!begin(p.x, p.y)) return;
e.preventDefault();
pid = e.pointerId;
svg.setPointerCapture(pid);
});
svg.addEventListener('pointermove', function (e) {
if (e.pointerId !== pid) return;
var p = local(e);
moveTo(p.x, p.y);
});
function end(e) { if (e.pointerId !== pid) return; pid = null; finish(); }
svg.addEventListener('pointerup', end);
svg.addEventListener('pointercancel', end);
}
render();
var auto = null;
if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
var every = +d.autoplay || DEFAULTS.autoplay;
var visible = false, timer = 0, step = 0;
var right = answer ? answer.split('-').map(function (k) { return +k - 1; }) : [0, 1, 2, 4, 6, 7, 8].filter(function (k) { return k < n * n; });
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
clearTimeout(timer);
if (visible) timer = setTimeout(play, 700);
}).observe(el);
// A random walk between neighbouring dots that isn't the answer.
function wrong() {
for (var tries = 0; tries < 30; tries++) {
var k = Math.floor(Math.random() * n * n), walk = [k];
while (walk.length < 4 + Math.floor(Math.random() * 2)) {
var c = k % n, r = Math.floor(k / n), opts = [];
for (var dr = -1; dr <= 1; dr++) for (var dc = -1; dc <= 1; dc++) {
var nc = c + dc, nr = r + dr, nk = nr * n + nc;
if ((dr || dc) && nc >= 0 && nc < n && nr >= 0 && nr < n && walk.indexOf(nk) < 0) opts.push(nk);
}
if (!opts.length) break;
k = opts[Math.floor(Math.random() * opts.length)];
walk.push(k);
}
if (walk.length >= 4 && walk.join() !== right.join()) return walk;
}
return [0, 3, 4, 5];
}
function play() {
if (!visible) return;
if (performance.now() - s.userAt < 6000 || s.drawing || s.busy) { timer = setTimeout(play, 900); return; }
var walk = step++ % 2 === 0 ? wrong() : right;
var pts = walk.map(function (k) { return dots[k]; });
var p = { t: 0 };
finger.setAttribute('cx', pts[0].x); finger.setAttribute('cy', pts[0].y);
anime.animate(finger, { opacity: [0, 1], duration: 200 });
timer = setTimeout(function () {
begin(pts[0].x, pts[0].y);
auto = anime.animate(p, {
t: pts.length - 1,
duration: (pts.length - 1) * 230,
ease: 'inOut(1.4)',
onUpdate: function () {
var i = Math.min(pts.length - 2, Math.floor(p.t)), f = p.t - i;
var x = pts[i].x + (pts[i + 1].x - pts[i].x) * f, y = pts[i].y + (pts[i + 1].y - pts[i].y) * f;
finger.setAttribute('cx', x); finger.setAttribute('cy', y);
moveTo(x, y);
},
onComplete: function () {
auto = null;
finish();
anime.animate(finger, { opacity: 0, duration: 250 });
timer = setTimeout(play, every + 900);
},
});
}, 240);
}
}
function init() {
document.querySelectorAll('[data-pattern-lock]').forEach(function (el) {
if (el._ptn) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-pattern-lock data-pattern="1-2-3-5-7-8-9" data-status style="width: 280px"></div>
<script>
document.querySelector('[data-pattern-lock]').addEventListener('pattern', (e) => {
if (e.detail.ok) console.log('Unlocked');
});
</script>