Pattern Lock
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes btn-pattern-lock-shake {
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
.btn-pattern-lock {
display: inline-flex;
align-items: center;
gap: 16px;
padding: 10px 22px 10px 10px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.btn-pattern-lock:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-pattern-lock[data-status='bad'] { animation: btn-pattern-lock-shake 0.4s ease; }
.btn-pattern-lock[data-status='ok'] {
border-color: light-dark(#50535b, #c6c8cd);
color: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 24px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent);
}
.btn-pattern-lock__pad {
width: 96px;
height: 96px;
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.btn-pattern-lock__line {
fill: none;
stroke: light-dark(#373a40, #f1f2f3);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd));
}
.btn-pattern-lock__dot { fill: light-dark(#cbced3, #393c41); transition: fill 0.2s ease; }
.btn-pattern-lock__dot[data-on] { fill: light-dark(#373a40, #f1f2f3); }
.btn-pattern-lock__halo {
fill: none;
stroke: light-dark(#50535b, #c6c8cd);
stroke-width: 1;
transform-box: fill-box;
transform-origin: center;
transform: scale(0.4);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}
.btn-pattern-lock__halo[data-on] { transform: scale(1); opacity: 1; }
.btn-pattern-lock__text { display: grid; min-width: 7.5em; text-align: left; }
.btn-pattern-lock__hint { font-size: 11px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.btn-pattern-lock[data-status='bad'] { animation: none; }
.btn-pattern-lock, .btn-pattern-lock__dot, .btn-pattern-lock__halo { transition: none; }
}
`;
const AT = [18, 48, 78];
const DOTS = AT.flatMap((y) => AT.map((x) => ({ x, y })));
const DEMO = [0, 1, 2, 4, 6, 7, 8];
export function PatternLockButton() {
const pad = useRef<SVGSVGElement>(null);
const [path, setPath] = useState<number[]>([]);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const [status, setStatusState] = useState<'idle' | 'drawing' | 'ok' | 'bad'>('idle');
// Mirrored in a ref: click fires right after pointerup, before React re-renders.
const statusRef = useRef(status);
const setStatus = (s: typeof status) => {
statusRef.current = s;
setStatusState(s);
};
const timers = useRef<number[]>([]);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const later = (fn: () => void, ms: number) => timers.current.push(window.setTimeout(fn, ms));
const finish = (p: number[]) => {
setCursor(null);
if (p.length >= 4) {
setStatus('ok');
later(() => {
setStatus('idle');
setPath([]);
}, 1800);
} else {
setStatus('bad');
later(() => {
setStatus('idle');
setPath([]);
}, 450);
}
};
const play = () => {
if (statusRef.current !== 'idle') return;
setStatus('drawing');
DEMO.forEach((d, i) =>
later(() => {
setPath(DEMO.slice(0, i + 1));
if (i === DEMO.length - 1) finish(DEMO);
}, 120 * (i + 1)),
);
};
const local = (e: React.PointerEvent) => {
const r = pad.current!.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * 96, y: ((e.clientY - r.top) / r.height) * 96 };
};
const hit = (p: { x: number; y: number }) => DOTS.findIndex((d) => Math.hypot(d.x - p.x, d.y - p.y) < 13);
const pts = path.map((i) => `${DOTS[i].x},${DOTS[i].y}`);
if (cursor && status === 'drawing' && path.length) pts.push(`${cursor.x},${cursor.y}`);
return (
<>
<style>
{css}
</style>
<button
type="button"
className="btn-pattern-lock"
data-status={status}
onPointerDown={(e) => {
if (status !== 'idle') return;
const i = hit(local(e));
if (i < 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setStatus('drawing');
setPath([i]);
}}
onPointerMove={(e) => {
if (status !== 'drawing' || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
const p = local(e);
setCursor(p);
const i = hit(p);
if (i >= 0 && !path.includes(i)) setPath((old) => [...old, i]);
}}
onPointerUp={(e) => {
if (!e.currentTarget.hasPointerCapture(e.pointerId)) return;
// A tap on one dot is a click, not a pattern: reset, and the click plays the demo.
if (path.length <= 1) {
setStatus('idle');
setPath([]);
setCursor(null);
} else finish(path);
}}
onClick={play}
>
<svg ref={pad} className="btn-pattern-lock__pad" viewBox="0 0 96 96" aria-hidden="true">
{pts.length > 1 && <polyline className="btn-pattern-lock__line" points={pts.join(' ')} />}
{DOTS.map((d, i) => (
<g key={i}>
<circle className="btn-pattern-lock__halo" cx={d.x} cy={d.y} r={9} data-on={path.includes(i) || undefined} />
<circle className="btn-pattern-lock__dot" cx={d.x} cy={d.y} r={3.5} data-on={path.includes(i) || undefined} />
</g>
))}
</svg>
<span className="btn-pattern-lock__text" aria-live="polite">
{status === 'ok' ? 'Unlocked' : 'Locked'}
<span className="btn-pattern-lock__hint">
{status === 'ok' ? 'Welcome back' : status === 'bad' ? 'Try 4+ dots' : 'Draw to unlock'}
</span>
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnPatternLock(el) on the mounted element instead.
<button type="button" class="btn-pattern-lock" data-status="idle">
<svg class="btn-pattern-lock__pad" viewBox="0 0 96 96" aria-hidden="true">
<g>
<circle class="btn-pattern-lock__halo" cx="18" cy="18" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="18" cy="18" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="48" cy="18" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="48" cy="18" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="78" cy="18" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="78" cy="18" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="18" cy="48" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="18" cy="48" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="48" cy="48" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="48" cy="48" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="78" cy="48" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="78" cy="48" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="18" cy="78" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="18" cy="78" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="48" cy="78" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="48" cy="78" r="3.5"></circle>
</g>
<g>
<circle class="btn-pattern-lock__halo" cx="78" cy="78" r="9"></circle>
<circle class="btn-pattern-lock__dot" cx="78" cy="78" r="3.5"></circle>
</g>
</svg>
<span class="btn-pattern-lock__text" aria-live="polite">Locked<span class="btn-pattern-lock__hint">Draw to unlock</span></span>
</button>@keyframes btn-pattern-lock-shake {
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
.btn-pattern-lock {
display: inline-flex;
align-items: center;
gap: 16px;
padding: 10px 22px 10px 10px;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 18px;
background: light-dark(#f3f4f7, #1a1c1f);
color: light-dark(#42454c, #e5e6e9);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
touch-action: none;
user-select: none;
-webkit-user-select: none;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.btn-pattern-lock:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-pattern-lock[data-status='bad'] { animation: btn-pattern-lock-shake 0.4s ease; }
.btn-pattern-lock[data-status='ok'] {
border-color: light-dark(#50535b, #c6c8cd);
color: light-dark(#373a40, #f1f2f3);
box-shadow: 0 0 24px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent);
}
.btn-pattern-lock__pad {
width: 96px;
height: 96px;
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035);
}
.btn-pattern-lock__line {
fill: none;
stroke: light-dark(#373a40, #f1f2f3);
stroke-width: 2.5;
stroke-linecap: round;
stroke-linejoin: round;
filter: drop-shadow(0 0 3px light-dark(#50535b, #c6c8cd));
}
.btn-pattern-lock__dot { fill: light-dark(#cbced3, #393c41); transition: fill 0.2s ease; }
.btn-pattern-lock__dot[data-on] { fill: light-dark(#373a40, #f1f2f3); }
.btn-pattern-lock__halo {
fill: none;
stroke: light-dark(#50535b, #c6c8cd);
stroke-width: 1;
transform-box: fill-box;
transform-origin: center;
transform: scale(0.4);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.2s ease;
}
.btn-pattern-lock__halo[data-on] { transform: scale(1); opacity: 1; }
.btn-pattern-lock__text { display: grid; min-width: 7.5em; text-align: left; }
.btn-pattern-lock__hint { font-size: 11px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); }
@media (prefers-reduced-motion: reduce) {
.btn-pattern-lock[data-status='bad'] { animation: none; }
.btn-pattern-lock, .btn-pattern-lock__dot, .btn-pattern-lock__halo { transition: none; }
}function initBtnPatternLock(root) {
var pad = root.querySelector('.btn-pattern-lock__pad');
var text = root.querySelector('.btn-pattern-lock__text');
var hint = root.querySelector('.btn-pattern-lock__hint');
if (!pad || !text || !hint) return;
var NS = 'http://www.w3.org/2000/svg';
var AT = [18, 48, 78];
var DOTS = [];
AT.forEach(function (y) { AT.forEach(function (x) { DOTS.push({ x: x, y: y }); }); });
var DEMO = [0, 1, 2, 4, 6, 7, 8];
var groups = pad.querySelectorAll('g');
var line = document.createElementNS(NS, 'polyline');
line.setAttribute('class', 'btn-pattern-lock__line');
pad.insertBefore(line, pad.firstChild);
var path = [];
var cursor = null;
var status = 'idle';
var timers = [];
function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
function render() {
root.setAttribute('data-status', status);
groups.forEach(function (g, i) {
var on = path.indexOf(i) >= 0;
g.querySelectorAll('circle').forEach(function (c) { c.toggleAttribute('data-on', on); });
});
var pts = path.map(function (i) { return DOTS[i].x + ',' + DOTS[i].y; });
if (cursor && status === 'drawing' && path.length) pts.push(cursor.x + ',' + cursor.y);
line.setAttribute('points', pts.length > 1 ? pts.join(' ') : '');
text.firstChild.textContent = status === 'ok' ? 'Unlocked' : 'Locked';
hint.textContent = status === 'ok' ? 'Welcome back' : status === 'bad' ? 'Try 4+ dots' : 'Draw to unlock';
}
function reset() {
status = 'idle';
path = [];
cursor = null;
render();
}
function finish() {
cursor = null;
status = path.length >= 4 ? 'ok' : 'bad';
render();
later(reset, status === 'ok' ? 1800 : 450);
}
function local(e) {
var r = pad.getBoundingClientRect();
return { x: ((e.clientX - r.left) / r.width) * 96, y: ((e.clientY - r.top) / r.height) * 96 };
}
function hit(p) {
for (var i = 0; i < DOTS.length; i++) if (Math.hypot(DOTS[i].x - p.x, DOTS[i].y - p.y) < 13) return i;
return -1;
}
function down(e) {
if (status !== 'idle') return;
var i = hit(local(e));
if (i < 0) return;
root.setPointerCapture(e.pointerId);
status = 'drawing';
path = [i];
render();
}
function move(e) {
if (status !== 'drawing' || !root.hasPointerCapture(e.pointerId)) return;
cursor = local(e);
var i = hit(cursor);
if (i >= 0 && path.indexOf(i) < 0) path.push(i);
render();
}
function up(e) {
if (!root.hasPointerCapture(e.pointerId)) return;
// A tap on one dot is a click, not a pattern: reset, and the click plays the demo.
if (path.length <= 1) reset();
else finish();
}
function play() {
if (status !== 'idle') return;
status = 'drawing';
render();
DEMO.forEach(function (d, i) {
later(function () {
path = DEMO.slice(0, i + 1);
if (i === DEMO.length - 1) finish();
else render();
}, 120 * (i + 1));
});
}
root.addEventListener('pointerdown', down);
root.addEventListener('pointermove', move);
root.addEventListener('pointerup', up);
root.addEventListener('click', play);
return function () {
timers.forEach(clearTimeout);
line.remove();
root.removeEventListener('pointerdown', down);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerup', up);
root.removeEventListener('click', play);
};
}
document.querySelectorAll('.btn-pattern-lock').forEach(initBtnPatternLock);Goal: Create a "Pattern Lock" button.
Visual behavior: A lock button with a 3x3 pattern pad. Drag through four or more dots to unlock: each dot lights with a springy halo and a glowing line follows your finger. Too short a pattern shakes it off; a plain click plays a demo pattern for you.
Technique:
- The pad is a 96x96 SVG: nine dots, each with a halo ring that springs from scale 0.4 to 1 when the dot joins the pattern.
- Pointer capture tracks a drag; the pointer position is mapped into the viewBox and hit-tested against the dots (13px radius). New dots append to the path.
- A polyline joins the chosen dots and a live segment runs to the pointer. Release with 4+ dots: Unlocked with a glow, reset after 1.8s; fewer: a shake keyframe and reset.
- A click without a drag plays a demo pattern one dot every 120ms; the status is kept in a ref because click fires before the next render.
Accessibility: A real <button>: Enter or Space plays the demo pattern, so it works without a pointer. The status text is an aria-live region (Locked / Unlocked). Focus outline.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Pattern Lock" button.
Visual behavior: A lock button with a 3x3 pattern pad. Drag through four or more dots to unlock: each dot lights with a springy halo and a glowing line follows your finger. Too short a pattern shakes it off; a plain click plays a demo pattern for you.
Technique:
- The pad is a 96x96 SVG: nine dots, each with a halo ring that springs from scale 0.4 to 1 when the dot joins the pattern.
- Pointer capture tracks a drag; the pointer position is mapped into the viewBox and hit-tested against the dots (13px radius). New dots append to the path.
- A polyline joins the chosen dots and a live segment runs to the pointer. Release with 4+ dots: Unlocked with a glow, reset after 1.8s; fewer: a shake keyframe and reset.
- A click without a drag plays a demo pattern one dot every 120ms; the status is kept in a ref because click fires before the next render.
Accessibility: A real <button>: Enter or Space plays the demo pattern, so it works without a pointer. The status text is an aria-live region (Locked / Unlocked). Focus outline.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A lock button with a 3x3 pattern pad. Drag through four or more dots to unlock: each dot lights with a springy halo and a glowing line follows your finger. Too short a pattern shakes it off; a plain click plays a demo pattern for you.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, draw, state change, spring
- Techniques
- svg, event position, state machine, keyframes
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button>: Enter or Space plays the demo pattern, so it works without a pointer. The status text is an aria-live region (Locked / Unlocked). Focus outline.