№ 018
Stamp PIN
A four-digit code input printed on a strip of postage stamps: each digit is inked with a quick press, then a wooden hand stamp comes down and marks the strip APPROVED or DENIED.
- Version
- 1.0.0
- Download
- 608 KB · 7 files
- Added
- Oct 26, 2026
- Built with
- HTML, CSS, JS
- Needs
- Google Fonts
- Tags
- otp, verification, paper, hand-drawn, svg, web audio
Usage
Copy stamp-pin.css and stamp-pin.js (and assets/, if there is one) into your project, then:
const pin = new StampPin(element, {
length: 4,
verify: async (code) => true, // required: ask your server. true / false, or { ok, message }
label: 'Verification code', // aria-label, or pass labelledBy: 'element-id'
autofocus: false,
sound: true, // a soft rubber-stamp thock per digit (Web Audio, synthesised)
assets: 'assets/', // where the stamp images live (default: next to this script),
// or a map { 'approved.webp': url, ... } for bundlers
onSuccess(code) {}, onError(code, message) {},
})
pin.fill('4829') // as if pasted or autofilled
pin.clear(); pin.focus(); pin.setDisabled(true); pin.setSound(false)
pin.value; pin.state // 'idle' | 'verifying' | 'success' | 'error' | 'disabled'
A one-time-code input drawn as a strip of postage stamps. Each digit is inked onto its stamp with a
short press. When the server answers, a wooden hand stamp comes down somewhere on the strip and
leaves its mark: a round green APPROVED, or a rectangular red DENIED that clears as soon as you edit.
Underneath it is one ordinary <input autocomplete="one-time-code">, stretched over the whole strip,
so paste, SMS autofill, screen readers and the keyboard all behave as they would on a plain field.
The stamps are one SVG, so the strip scales as a single unit.Source
/* Stamp PIN — a one-time-code input printed on a strip of postage stamps. */
.sp {
--sp-ink: #292722;
--sp-post: #b6574f;
--sp-ok: #4b7a50;
--sp-numeral: "Playfair Display", "Bodoni Moda", Georgia, serif;
--sp-ui: "Jost", ui-sans-serif, system-ui, sans-serif;
position: relative;
width: 100%;
max-width: var(--sp-max);
margin: 0 auto;
container-type: inline-size;
-webkit-tap-highlight-color: transparent;
}
.sp-field {
position: relative;
width: 100%;
}
.sp-art {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
transition: opacity 200ms ease, filter 200ms ease;
}
/* The one real control: covers the whole stamp row, so every stamp is a tap target. */
.sp-input {
position: absolute;
margin: 0;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: transparent;
caret-color: transparent;
font-size: 16px; /* at least 16px, or iOS zooms on focus */
letter-spacing: 40px;
text-indent: -999px;
outline: none;
cursor: text;
-webkit-text-fill-color: transparent;
appearance: none;
}
.sp-input::selection { background: transparent; }
.sp-input:disabled { cursor: not-allowed; }
.sp-input:-webkit-autofill { -webkit-box-shadow: 0 0 0 100px transparent inset; transition: background-color 99999s; }
/* ── stamps ── */
.sp-stamp {
transform-box: fill-box;
transform-origin: 50% 100%;
}
.sp-num,
.sp-wet {
font-family: var(--sp-numeral);
font-weight: 800;
font-variant-numeric: lining-nums tabular-nums;
font-feature-settings: "lnum", "tnum";
transform-box: fill-box;
transform-origin: 50% 60%;
}
.sp-wet { opacity: 0; }
.sp-grain { mix-blend-mode: multiply; opacity: 0.4; }
/* empty: a faint guide where the numeral will sit */
.sp-guide {
opacity: 0.2;
transition: opacity 120ms ease;
}
.is-filled .sp-guide { opacity: 0; }
/* the active stamp: an inner ink outline and a darker, blinking guide */
.sp-focus {
opacity: 0;
transition: opacity 90ms ease;
}
.is-active .sp-focus { opacity: 0.9; }
.is-caret:not(.is-filled) .sp-guide {
opacity: 0.85;
animation: sp-blink 1.06s steps(1) infinite;
}
.is-caret.is-filled .sp-guide { opacity: 0; }
@keyframes sp-blink { 50% { opacity: 0.18; } }
/* ── the hand stamp and its impression ── */
.sp-marks {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
.sp-ink {
position: absolute;
height: auto;
mix-blend-mode: multiply; /* ink soaks into whatever paper it lands on */
user-select: none;
}
.sp-ink-approved { opacity: 0.94; }
.sp-tool {
position: absolute;
z-index: 3;
}
.sp-tool-body,
.sp-tool-shadow {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform-origin: inherit;
opacity: 0;
user-select: none;
}
/* the tool's own silhouette, darkened and blurred: a real cast shadow rather than a generic blob */
.sp-tool-shadow { filter: brightness(0) blur(6px); }
.sp-art { transform-origin: 50% 100%; }
/* ── error ── */
.sp-error-rule {
fill: none;
stroke-dasharray: 1 1;
stroke-dashoffset: 1;
opacity: 0;
transition: stroke-dashoffset 260ms cubic-bezier(.3, .6, .25, 1), opacity 120ms ease;
}
.sp-error:not(.sp-still) .sp-error-rule { transition-delay: 460ms, 460ms; }
.sp-error .sp-error-rule { stroke-dashoffset: 0; opacity: 0.9; }
/* ── status, outside the stamp row ── */
.sp-status {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
min-height: 38px;
margin: 6px 0 0;
font: 400 14px/1.35 var(--sp-ui);
color: #5d554b;
text-align: center;
}
.sp-status:empty { visibility: hidden; }
.sp-verifying .sp-status {
font-size: 12px;
letter-spacing: 0.2em;
text-transform: uppercase;
opacity: 0;
}
.sp-pending .sp-status { opacity: 1; transition: opacity 110ms ease; }
.sp-spin {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1.5px dashed currentColor;
animation: sp-spin 1.4s linear infinite;
}
@keyframes sp-spin { to { transform: rotate(360deg); } }
.sp-success .sp-status {
justify-self: center;
width: max-content;
margin: 6px auto 0;
padding: 7px 16px 7px 12px;
border: 1.5px solid var(--sp-ok);
border-radius: 7px;
color: var(--sp-ok);
font-size: 15px;
font-weight: 500;
letter-spacing: 0.22em;
text-transform: uppercase;
animation: sp-badge 260ms 520ms cubic-bezier(.2, .8, .3, 1) backwards;
}
@keyframes sp-badge { from { opacity: 0; transform: translateY(4px); } }
.sp-check { width: 20px; height: 20px; }
.sp-check circle { fill: var(--sp-ok); }
.sp-check path { fill: none; stroke: #f5f0e6; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sp-error .sp-status { color: #9b3f38; animation: sp-fade 220ms 460ms backwards; }
.sp-alert { flex: none; width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; }
/* ── disabled ── */
.sp-disabled .sp-art { opacity: 0.55; filter: saturate(0.35); }
.sp-disabled .sp-guide { animation: none; }
/* ── small screens: the second rule goes before the numerals get small ── */
@container (max-width: 330px) {
.sp-frame-fine { display: none; }
.sp-status { font-size: 13px; }
}
/* reduced motion: numerals appear at once, nothing blinks, the stamp mark only fades */
.sp-still .is-caret:not(.is-filled) .sp-guide { animation: none; }
.sp-still .sp-spin { animation: none; border-style: solid; border-right-color: transparent; }
.sp-still.sp-success .sp-status,
.sp-still.sp-error .sp-status { animation: sp-fade 200ms backwards; }
@keyframes sp-fade { from { opacity: 0; } } /* Stamp PIN — usage:
* const pin = new StampPin(element, {
* length: 4,
* verify: async (code) => true, // required: ask your server. true / false, or { ok, message }
* label: 'Verification code', // aria-label, or pass labelledBy: 'element-id'
* autofocus: false,
* sound: true, // a soft rubber-stamp thock per digit (Web Audio, synthesised)
* assets: 'assets/', // where the stamp images live (default: next to this script),
* // or a map { 'approved.webp': url, ... } for bundlers
* onSuccess(code) {}, onError(code, message) {},
* })
* pin.fill('4829') // as if pasted or autofilled
* pin.clear(); pin.focus(); pin.setDisabled(true); pin.setSound(false)
* pin.value; pin.state // 'idle' | 'verifying' | 'success' | 'error' | 'disabled'
*
* A one-time-code input drawn as a strip of postage stamps. Each digit is inked onto its stamp with a
* short press. When the server answers, a wooden hand stamp comes down somewhere on the strip and
* leaves its mark: a round green APPROVED, or a rectangular red DENIED that clears as soon as you edit.
* Underneath it is one ordinary <input autocomplete="one-time-code">, stretched over the whole strip,
* so paste, SMS autofill, screen readers and the keyboard all behave as they would on a plain field.
* The stamps are one SVG, so the strip scales as a single unit.
*/
(function () {
const NS = 'http://www.w3.org/2000/svg';
const W = 58, H = 84, GAP = 7; // one stamp, and the gap between stamps (viewBox units = CSS px at full size)
const HOLE = 2.2; // perforation radius
const INSET = 7; // inner frame, from the paper edge
const PAD_X = 16, PAD_TOP = 10, PAD_BOTTOM = 22; // room for the shadows and the error rule
const NUM_SIZE = 38, BASELINE = 55.5; // every numeral shares this baseline
const PAPERS = ['#f9e3a4', '#fbf7ee', '#a8c6d5', '#e0907f', '#f9e3a4', '#fbf7ee'];
const INK = '#292722';
const POST = '#b6574f'; // error ink
const PRESS_MS = 160, INK_MS = 150; // per-digit impression, well under 200 ms
const SETTLE_MS = 100; // the beat between the last digit and the pending cue
// The two hand stamps. Sizes in viewBox units; `face` is where the rubber sits in the tool image
// (as a share of its height), so the impression lands exactly under it.
const SCRIPT_BASE = document.currentScript ? new URL('assets/', document.currentScript.src).href : 'assets/';
const MARKS = {
approved: { ink: 'approved.webp', tool: 'tool-round.webp', w: 92, aspect: 614 / 640, toolW: 106, toolAspect: 648 / 640, face: 0.506, tilt: 24 },
denied: { ink: 'denied.webp', tool: 'tool-rect.webp', w: 150, aspect: 250 / 900, toolW: 166, toolAspect: 442 / 900, face: 0.584, tilt: 12 },
};
const OVERHANG = 7; // how far a mark may run past the strip's edge
const STAMP_MS = 940, CONTACT = 0.47, LIFT = 0.57; // the whole hand-stamp move; when the rubber meets the paper, and leaves it
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
let uid = 0;
const el = (name, attrs, parent) => {
const n = document.createElementNS(NS, name);
for (const k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
};
const f2 = (n) => +n.toFixed(2);
// mulberry32: the hand-inked wobble is the same on every load
const rng = (seed) => () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
// The paper outline: a rectangle with half-round bites along every edge, solid at the corners.
// Walking clockwise, sweep-flag 0 bends each bite into the paper.
function perforated(w, h, r) {
const nx = Math.round(w / 6.4), ny = Math.round(h / 6.4);
const px = w / nx, py = h / ny;
let d = 'M0 0';
for (let i = 0; i < nx; i++) { const x = (i + 0.5) * px; d += `L${f2(x - r)} 0A${r} ${r} 0 0 0 ${f2(x + r)} 0`; }
d += `L${w} 0`;
for (let i = 0; i < ny; i++) { const y = (i + 0.5) * py; d += `L${w} ${f2(y - r)}A${r} ${r} 0 0 0 ${w} ${f2(y + r)}`; }
d += `L${w} ${h}`;
for (let i = nx - 1; i >= 0; i--) { const x = (i + 0.5) * px; d += `L${f2(x + r)} ${h}A${r} ${r} 0 0 0 ${f2(x - r)} ${h}`; }
d += `L0 ${h}`;
for (let i = ny - 1; i >= 0; i--) { const y = (i + 0.5) * py; d += `L0 ${f2(y + r)}A${r} ${r} 0 0 0 0 ${f2(y - r)}`; }
return d + 'Z';
}
// A hand-ruled rectangle: straight in intent, with a slight drift of the pen. Corners stay put.
function inked(x, y, w, h, wob, rand) {
const pts = [];
const side = (x0, y0, x1, y1) => {
const len = Math.hypot(x1 - x0, y1 - y0), n = Math.max(2, Math.round(len / 7));
const nxv = -(y1 - y0) / len, nyv = (x1 - x0) / len;
for (let i = 0; i < n; i++) {
const t = i / n, j = i === 0 ? 0 : (rand() - 0.5) * 2 * wob;
pts.push([x0 + (x1 - x0) * t + nxv * j, y0 + (y1 - y0) * t + nyv * j]);
}
};
side(x, y, x + w, y); side(x + w, y, x + w, y + h); side(x + w, y + h, x, y + h); side(x, y + h, x, y);
return 'M' + pts.map((p) => `${f2(p[0])} ${f2(p[1])}`).join('L') + 'Z';
}
const shade = (hex, k) => {
const n = parseInt(hex.slice(1), 16);
const c = [n >> 16, (n >> 8) & 255, n & 255].map((v) => Math.round(k < 0 ? v * (1 + k) : v + (255 - v) * k));
return `rgb(${c.join(',')})`;
};
/* ── sound: a rubber stamp on paper ── */
// Laptop and phone speakers play almost nothing below ~150 Hz, so every hit keeps its weight in the
// low mids (a tom-like body, saturated so its harmonics carry) and its definition in a wooden knock.
class StampSound {
// Created lazily, and only after the page has had a user gesture, so it never trips autoplay rules.
ensure() {
if (!this.ctx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC || (navigator.userActivation && !navigator.userActivation.hasBeenActive)) return null;
const ctx = (this.ctx = new AC());
const len = Math.floor(ctx.sampleRate * 1.2);
this.noise = ctx.createBuffer(1, len, ctx.sampleRate);
const d = this.noise.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
// soft saturation curve: turns a low thump into harmonics small speakers can play
this.curve = new Float32Array(1024);
for (let i = 0; i < 1024; i++) { const x = (i / 1023) * 2 - 1; this.curve[i] = Math.tanh(3.2 * x) / Math.tanh(3.2); }
// a limiter: the DUM stacks several layers and must never clip
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -14;
comp.knee.value = 6;
comp.ratio.value = 8;
comp.attack.value = 0.002;
comp.release.value = 0.1;
comp.connect(ctx.destination);
this.out = ctx.createGain();
this.out.gain.value = 0.9;
this.out.connect(comp);
}
if (this.ctx.state === 'suspended') this.ctx.resume();
return this.ctx;
}
// one digit inked: a short, dry wooden tap
thock(weight = 1) {
const ctx = this.ensure();
if (!ctx) return;
const t = ctx.currentTime + 0.005;
const p = (1 + (Math.random() - 0.5) * 0.08) / Math.sqrt(weight);
this.tone(this.out, t, 'triangle', 320 * p, 150 * p, 0.04, 0.42, 0.08, true);
this.tone(this.out, t, 'sine', 170 * p, 90 * p, 0.05, 0.3, 0.1);
this.hiss(this.out, t, 'bandpass', 1500 * p, 1.2, [[0.0015, 0.75], [0.025, 0]]);
}
/* the hand stamp: every layer is scheduled on the audio clock against the animation's timeline,
through one bus that can be cut if the stamping is interrupted */
// a decaying tone: pitch falls from f0 to f1, so it lands like a blow rather than a beep
tone(bus, t, type, f0, f1, fall, peak, decay, drive) {
const ctx = this.ctx, o = ctx.createOscillator(), g = ctx.createGain();
o.type = type;
o.frequency.setValueAtTime(f0, t);
o.frequency.exponentialRampToValueAtTime(f1, t + fall);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(peak, t + 0.003);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
if (drive) {
const sh = ctx.createWaveShaper();
sh.curve = this.curve;
o.connect(sh).connect(g);
} else o.connect(g);
g.connect(bus);
o.start(t);
o.stop(t + decay + 0.05);
}
// filtered noise with a shaped envelope: [[time, gain], ...] relative to t
hiss(bus, t, filter, freq, q, env, sweepTo) {
const ctx = this.ctx, src = ctx.createBufferSource(), f = ctx.createBiquadFilter(), g = ctx.createGain();
src.buffer = this.noise;
src.loop = true;
f.type = filter;
f.Q.value = q;
f.frequency.setValueAtTime(freq, t);
const end = env[env.length - 1][0];
if (sweepTo) f.frequency.exponentialRampToValueAtTime(sweepTo, t + end);
g.gain.setValueAtTime(0.0001, t);
for (const [dt, v] of env) g.gain.linearRampToValueAtTime(Math.max(0.0001, v), t + dt);
src.connect(f).connect(g).connect(bus);
src.start(t, Math.random() * 0.5);
src.stop(t + end + 0.02);
}
// DUM: a wooden block driven into paper on a desk.
dum(bus, t, low) {
const p = low ? 0.86 : 1;
this.tone(bus, t, 'sine', 190 * p, 70 * p, 0.09, 0.95, 0.34, true); // the thump, saturated so it carries
this.tone(bus, t, 'triangle', 270 * p, 150 * p, 0.07, 0.5, 0.22, true); // "dum": the hollow body of the block
this.tone(bus, t, 'sine', 430 * p, 250 * p, 0.05, 0.22, 0.12); // a second mode of the wood
this.tone(bus, t, 'sine', 62 * p, 50 * p, 0.25, 0.35, 0.5); // desk underneath (felt on big speakers)
this.hiss(bus, t, 'bandpass', 1100 * p, 1, [[0.002, 1.1], [0.045, 0]]); // the knock of contact
this.hiss(bus, t, 'highpass', 1800, 0.7, [[0.001, 0.6], [0.018, 0]]); // rubber slap on paper
// the handle settles in the block a beat later: a small "tk"
this.tone(bus, t + 0.075, 'triangle', 620 * p, 430 * p, 0.03, 0.14, 0.06);
this.hiss(bus, t + 0.075, 'bandpass', 2200, 2, [[0.001, 0.32], [0.022, 0]]);
}
// A small desk bell. Real bells are inharmonic: partials at 1, 2.76, 5.40 and 8.93 × the
// fundamental, the high ones dying first, which is what makes it "ting" rather than beep.
bell(bus, t, f, gain) {
const parts = [[1, 1, 1.4], [2.76, 0.42, 0.7], [5.4, 0.2, 0.35], [8.93, 0.09, 0.18]];
for (const [ratio, amp, decay] of parts) {
const ctx = this.ctx, o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine';
o.frequency.value = f * ratio;
o.detune.value = (Math.random() - 0.5) * 6;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * amp, t + 0.004);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
o.connect(g).connect(bus);
o.start(t);
o.stop(t + decay + 0.05);
}
// the striker's tiny click
this.hiss(bus, t, 'highpass', 5000, 0.7, [[0.001, gain * 0.25], [0.006, 0]]);
}
// A soft wooden bar, muffled: a marimba's fundamental with its 4× and 10× partials, lowpassed.
// Warm rather than alarming, so a wrong code sounds like "not quite" and not like a buzzer.
bar(bus, t, f, gain) {
const ctx = this.ctx, lp = ctx.createBiquadFilter();
lp.type = 'lowpass';
lp.frequency.value = 1400;
lp.Q.value = 0.5;
lp.connect(bus);
for (const [ratio, amp, decay] of [[1, 1, 0.42], [2, 0.18, 0.2], [4, 0.3, 0.12], [10, 0.08, 0.04]]) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.type = 'sine';
o.frequency.value = f * ratio;
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(gain * amp, t + 0.005);
g.gain.exponentialRampToValueAtTime(0.0001, t + decay);
o.connect(g).connect(lp);
o.start(t);
o.stop(t + decay + 0.05);
}
this.hiss(bus, t, 'bandpass', 900, 1.5, [[0.001, gain * 0.35], [0.015, 0]]); // the mallet
}
// the verdict, just after the DUM has spoken
verdict(bus, t, kind) {
if (kind === 'approved') {
// "ting-ting": C6 then G6, a bright rising fifth
this.bell(bus, t, 1046.5, 0.2);
this.bell(bus, t + 0.11, 1568, 0.17);
} else {
// "dum-dum" down a minor third, D4 then B3: a gentle, unmistakable no
this.bar(bus, t, 293.7, 0.2);
this.bar(bus, t + 0.16, 246.9, 0.18);
}
}
// the whole move; times in seconds from now, matching the animation's keyframes
stamp(kind, times) {
const ctx = this.ensure();
if (!ctx) return;
this.cut();
const bus = (this.bus = ctx.createGain());
bus.connect(this.out);
const t = ctx.currentTime + 0.01, { land, lift, gone } = times;
const low = kind === 'denied';
// swoop in: air rising in pitch as the stamp nears the paper, cut dead at contact
this.hiss(bus, t, 'bandpass', 520, 0.7, [[land * 0.35, 0.1], [land * 0.82, 0.36], [land - 0.01, 0.26], [land, 0]], 2300);
this.dum(bus, t + land, low);
this.verdict(bus, t + land + (low ? 0.2 : 0.15), kind); // once the thump has died down
// lifting: the inked rubber unsticks from the paper in a few tiny crackles
for (let i = 0; i < 7; i++) {
const at = t + lift + i * 0.011 + Math.random() * 0.006;
this.hiss(bus, at, 'highpass', 2400 + Math.random() * 1800, 0.8, [[0.001, 0.12 + Math.random() * 0.1], [0.008, 0]]);
}
// and away: air falling in pitch
this.hiss(bus, t + lift + 0.02, 'bandpass', 1900, 0.7, [[0.08, 0.28], [(gone - lift) * 0.6, 0.14], [gone - lift, 0]], 520);
}
// just the impact and the verdict, for reduced motion
stampStill(kind) {
const ctx = this.ensure();
if (!ctx) return;
this.cut();
const bus = (this.bus = ctx.createGain());
bus.connect(this.out);
this.dum(bus, ctx.currentTime + 0.01, kind === 'denied');
this.verdict(bus, ctx.currentTime + (kind === 'denied' ? 0.21 : 0.16), kind);
}
// stop whatever is left of an interrupted stamping
cut() {
if (!this.bus) return;
const g = this.bus.gain, now = this.ctx.currentTime;
g.cancelScheduledValues(now);
g.setValueAtTime(g.value, now);
g.linearRampToValueAtTime(0, now + 0.03);
const old = this.bus;
setTimeout(() => old.disconnect(), 80);
this.bus = null;
}
}
/* ── the component ── */
class StampPin {
constructor(root, opts = {}) {
this.root = root;
this.opts = opts;
this.n = opts.length || 4;
// a folder, or (for bundlers) a map of file name → URL
this.assetMap = opts.assets && typeof opts.assets === 'object' ? opts.assets : null;
this.base = typeof opts.assets === 'string' ? new URL(opts.assets, location.href).href.replace(/\/?$/, '/') : SCRIPT_BASE;
this.verifyFn = opts.verify;
this.soundOn = opts.sound !== false;
this.sound = new StampSound();
this.id = 'sp' + ++uid;
this._state = 'idle';
this.token = 0;
this.prev = '';
this.build();
this.bind();
this.render();
if (opts.autofocus) this.input.focus({ preventScroll: true });
}
get value() { return this.input.value; }
get state() { return this.disabled ? 'disabled' : this._state; }
build() {
const { n, id } = this;
const stripW = n * W + (n - 1) * GAP;
this.stripW = stripW;
const vbW = stripW + PAD_X * 2, vbH = H + PAD_TOP + PAD_BOTTOM;
const rand = rng(0x5eed + n);
this.root.classList.add('sp');
this.root.style.setProperty('--sp-max', vbW + 'px');
this.root.innerHTML = '';
const field = document.createElement('div');
field.className = 'sp-field';
field.style.aspectRatio = `${vbW} / ${vbH}`;
this.root.appendChild(field);
const svg = (this.svg = el('svg', { class: 'sp-art', viewBox: `${-PAD_X} ${-PAD_TOP} ${vbW} ${vbH}`, 'aria-hidden': 'true', focusable: 'false' }, field));
const defs = el('defs', {}, svg);
// soft shadow down and to the right, from one upper-left light
const sh = el('filter', { id: `${id}-shadow`, x: '-20%', y: '-20%', width: '150%', height: '150%' }, defs);
el('feGaussianBlur', { stdDeviation: 1.7 }, sh);
// paper fibre: very fine, very faint
const gr = el('filter', { id: `${id}-grain`, x: 0, y: 0, width: '100%', height: '100%', 'color-interpolation-filters': 'sRGB' }, defs);
el('feTurbulence', { type: 'fractalNoise', baseFrequency: '1.3 0.8', numOctaves: 2, seed: 7, result: 'n' }, gr);
el('feColorMatrix', { values: '0 0 0 0 0.36 0 0 0 0 0.3 0 0 0 0 0.22 0.55 0 0 0 -0.2' }, gr);
// printed ink: tiny gaps in the coverage and a slightly soft edge, never enough to cost legibility
const ink = el('filter', { id: `${id}-ink`, x: '-10%', y: '-10%', width: '120%', height: '120%' }, defs);
el('feTurbulence', { type: 'fractalNoise', baseFrequency: 0.9, numOctaves: 2, seed: 3, result: 't' }, ink);
el('feDisplacementMap', { in: 'SourceGraphic', in2: 't', scale: 0.45, xChannelSelector: 'R', yChannelSelector: 'G', result: 'd' }, ink);
el('feColorMatrix', { in: 't', type: 'matrix', values: '0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -9 7.3', result: 'specks' }, ink);
el('feComposite', { in: 'd', in2: 'specks', operator: 'in' }, ink);
// diffuse light from the upper left across each sheet
const lg = el('linearGradient', { id: `${id}-light`, x1: 0, y1: 0, x2: 1, y2: 1 }, defs);
el('stop', { offset: 0, 'stop-color': '#fff', 'stop-opacity': 0.34 }, lg);
el('stop', { offset: 0.45, 'stop-color': '#fff', 'stop-opacity': 0 }, lg);
el('stop', { offset: 1, 'stop-color': '#5a4632', 'stop-opacity': 0.1 }, lg);
const paperD = perforated(W, H, HOLE);
const clip = el('clipPath', { id: `${id}-papers` }, defs);
for (let i = 0; i < n; i++) el('path', { d: paperD, transform: `translate(${i * (W + GAP)} 0)` }, clip);
const shadows = el('g', { class: 'sp-shadows' }, svg);
const strip = el('g', { class: 'sp-strip' }, svg);
this.stamps = [];
for (let i = 0; i < n; i++) {
const paper = PAPERS[i % PAPERS.length];
const x = i * (W + GAP);
el('path', { d: paperD, transform: `translate(${x + 1.3} 2.2)`, fill: '#3b2a18', opacity: 0.24, filter: `url(#${id}-shadow)` }, shadows);
el('path', { d: paperD, transform: `translate(${x + 0.35} 0.6)`, fill: '#3b2a18', opacity: 0.2 }, shadows);
const pos = el('g', { transform: `translate(${x} 0)` }, strip);
const g = el('g', { class: 'sp-stamp' }, pos);
el('path', { class: 'sp-paper', d: paperD, fill: paper }, g);
el('rect', { class: 'sp-panel', x: INSET, y: INSET, width: W - INSET * 2, height: H - INSET * 2, fill: shade(paper, paper === '#fbf7ee' ? -0.015 : -0.035) }, g);
el('path', { class: 'sp-frame', d: inked(INSET, INSET, W - INSET * 2, H - INSET * 2, 0.22, rand), fill: 'none', stroke: INK, 'stroke-opacity': 0.62, 'stroke-width': 0.85, 'stroke-linejoin': 'round' }, g);
el('path', { class: 'sp-frame-fine', d: inked(INSET + 2, INSET + 2, W - INSET * 2 - 4, H - INSET * 2 - 4, 0.16, rand), fill: 'none', stroke: INK, 'stroke-opacity': 0.34, 'stroke-width': 0.45 }, g);
el('path', { class: 'sp-edge', d: paperD, fill: 'none', stroke: '#4a3a28', 'stroke-opacity': 0.22, 'stroke-width': 0.5 }, g);
el('path', { d: paperD, fill: `url(#${id}-light)`, 'pointer-events': 'none' }, g);
el('rect', { class: 'sp-focus', x: 3.6, y: 3.6, width: W - 7.2, height: H - 7.2, rx: 1.6, fill: 'none', stroke: INK, 'stroke-width': 1.4 }, g);
el('rect', { class: 'sp-guide', x: W / 2 - 6, y: BASELINE + 3, width: 12, height: 1.4, rx: 0.7, fill: INK }, g);
const wet = el('text', { class: 'sp-wet', x: W / 2, y: BASELINE, 'font-size': NUM_SIZE, 'text-anchor': 'middle', fill: INK, stroke: INK, 'stroke-width': 2.4 }, g);
const num = el('text', { class: 'sp-num', x: W / 2, y: BASELINE, 'font-size': NUM_SIZE, 'text-anchor': 'middle', fill: INK, filter: `url(#${id}-ink)` }, g);
this.stamps.push({ g, num, wet });
}
el('rect', { x: -PAD_X, y: -PAD_TOP, width: vbW, height: vbH, fill: '#fff', filter: `url(#${id}-grain)`, 'clip-path': `url(#${id}-papers)`, class: 'sp-grain', 'pointer-events': 'none' }, svg);
// warm red detail at the base of the group, for a rejected code
el('path', { class: 'sp-error-rule', d: `M0 ${H + 11}H${stripW}`, stroke: POST, 'stroke-width': 1.5, 'stroke-linecap': 'round', pathLength: 1 }, svg);
// the hand stamps and their impressions live above the art, in viewBox-relative percentages
this.vb = { w: vbW, h: vbH };
this.marks = document.createElement('div');
this.marks.className = 'sp-marks';
this.marks.setAttribute('aria-hidden', 'true');
field.appendChild(this.marks);
this.preload();
const input = (this.input = document.createElement('input'));
Object.assign(input, { type: 'text', inputMode: 'numeric', autocomplete: 'one-time-code', maxLength: this.n, spellcheck: false, className: 'sp-input' });
input.setAttribute('pattern', '[0-9]*');
input.setAttribute('autocorrect', 'off');
input.setAttribute('autocapitalize', 'off');
input.name = this.opts.name || 'one-time-code';
if (this.opts.labelledBy) input.setAttribute('aria-labelledby', this.opts.labelledBy);
else input.setAttribute('aria-label', this.opts.label || `${this.n}-digit verification code`);
// the input spans the stamps only, not the padding around the art
input.style.left = (PAD_X / vbW) * 100 + '%';
input.style.width = (stripW / vbW) * 100 + '%';
input.style.top = (PAD_TOP / vbH) * 100 + '%';
input.style.height = (H / vbH) * 100 + '%';
field.appendChild(input);
const status = (this.status = document.createElement('p'));
status.className = 'sp-status';
status.id = `${id}-status`;
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
this.root.appendChild(status);
input.setAttribute('aria-describedby', status.id);
}
bind() {
const { input } = this;
this.sync = () => {
if (this.syncQueued) return;
this.syncQueued = true;
requestAnimationFrame(() => { this.syncQueued = false; this.renderCaret(); });
};
// Overwrite, the way people expect code boxes to behave: a digit typed at a filled position replaces it.
input.addEventListener('beforeinput', (e) => {
if (this.locked()) { e.preventDefault(); return; }
if (e.inputType !== 'insertText' || e.data == null) return;
const digits = e.data.replace(/\D/g, '');
e.preventDefault();
if (!digits) return;
const v = input.value, s = input.selectionStart ?? v.length, end = input.selectionEnd ?? s;
if (digits.length > 1) { this.insertMany(digits, s, end); return; }
if (s === end && s >= this.n) return;
const next = (v.slice(0, s) + digits + v.slice(s === end ? s + 1 : end)).slice(0, this.n);
this.commit(next, s + 1, 'type');
});
input.addEventListener('paste', (e) => {
e.preventDefault();
if (this.locked()) return;
const text = (e.clipboardData || window.clipboardData).getData('text') || '';
const digits = text.replace(/\D/g, '');
if (!digits) return;
if (digits.length >= this.n) this.commit(digits.slice(0, this.n), this.n, 'paste');
else this.insertMany(digits, input.selectionStart ?? 0, input.selectionEnd ?? 0);
});
// Everything else (backspace, cut, autofill, IME) goes through the native field; we only clean up after it.
input.addEventListener('input', () => {
const clean = input.value.replace(/\D/g, '').slice(0, this.n);
const caret = Math.min(input.selectionStart ?? clean.length, clean.length);
if (clean !== input.value) input.value = clean;
this.commit(clean, caret, 'native');
});
for (const ev of ['focus', 'blur', 'select', 'keyup', 'click']) input.addEventListener(ev, this.sync);
document.addEventListener('selectionchange', () => { if (document.activeElement === input) this.sync(); });
// Clicking a stamp puts the caret on it (never past the last filled one).
input.addEventListener('pointerup', (e) => {
if (this.locked() || e.button !== 0) return;
const box = input.getBoundingClientRect();
const unit = box.width / this.stripW;
const i = Math.floor((e.clientX - box.left) / unit / (W + GAP));
const at = Math.max(0, Math.min(i, input.value.length, this.n));
requestAnimationFrame(() => { input.setSelectionRange(at, at); this.sync(); });
});
// Wake the audio engine on the first click or key anywhere on the page. The stamp sound starts after
// an async server check, outside any gesture, so the engine must already be running by then.
const unlock = () => {
if (!this.soundOn) return;
if (this.sound.ensure()) { document.removeEventListener('pointerdown', unlock, true); document.removeEventListener('keydown', unlock, true); }
};
document.addEventListener('pointerdown', unlock, true);
document.addEventListener('keydown', unlock, true);
this.onMotion = () => this.root.classList.toggle('sp-still', reducedMotion.matches);
this.onMotion();
reducedMotion.addEventListener?.('change', this.onMotion);
}
locked() { return this.disabled || this._state === 'verifying' || this._state === 'success'; }
insertMany(digits, s, end) {
const v = this.input.value;
const next = (v.slice(0, s) + digits + v.slice(end)).slice(0, this.n);
this.commit(next, Math.min(this.n, s + digits.length), 'paste');
}
// The single path every change takes: write the value, draw it, animate only what changed.
commit(next, caret, how) {
const prev = this.prev;
this.input.value = next;
this.input.setSelectionRange(caret, caret);
this.prev = next;
if (this._state === 'error' && next !== prev) { this.clearMarks(); this.setState('idle'); }
const changed = [];
for (let i = 0; i < this.n; i++) if (next[i] && next[i] !== prev[i]) changed.push(i);
this.render();
// a shorter value is a deletion: the stamps simply clear, no reverse impression
if (next.length >= prev.length && changed.length) {
if (how === 'paste' || how === 'fill' || changed.length > 1) this.impress(changed, true);
else this.impress(changed, false);
}
if (next.length === this.n && next !== prev) this.submit(next);
}
impress(indices, together) {
if (this.disabled) return;
if (this.soundOn) this.sound.thock(together ? 1.25 : 1);
if (reducedMotion.matches) return;
for (const i of indices) {
const s = this.stamps[i];
// cancel, never queue: fast typing always shows the latest press only
for (const node of [s.g, s.num, s.wet]) node.getAnimations().forEach((a) => a.cancel());
s.g.animate(
[{ transform: 'scale(1, 1)' }, { transform: 'scale(1.006, 0.974)', offset: 0.32 }, { transform: 'scale(1, 1)' }],
{ duration: together ? 200 : PRESS_MS, easing: 'cubic-bezier(.25,.7,.3,1)' },
);
s.num.animate(
[{ opacity: 0, transform: 'scale(1.06)' }, { opacity: 0.92, offset: 0.45 }, { opacity: 1, transform: 'scale(1)' }],
{ duration: INK_MS, easing: 'cubic-bezier(.2,.8,.3,1)' },
);
s.wet.animate([{ opacity: 0.28 }, { opacity: 0 }], { duration: INK_MS + 40, easing: 'ease-out' });
}
}
submit(code) {
const token = ++this.token;
this.setState('verifying');
this.root.classList.remove('sp-pending');
this.cancelLater(this.pendingTimer);
this.pendingTimer = this.later(() => { if (token === this.token) this.root.classList.add('sp-pending'); }, SETTLE_MS);
Promise.resolve()
.then(() => (this.verifyFn ? this.verifyFn(code) : false))
.then((res) => {
if (token !== this.token) return;
const ok = typeof res === 'object' && res !== null ? !!res.ok : !!res;
if (ok) this.succeed(code);
else this.reject(code, (res && res.message) || 'That code doesn’t match. Check the latest message and try again.');
})
.catch(() => {
if (token === this.token) this.reject(code, 'We couldn’t check that code. Try again in a moment.');
});
}
succeed(code) {
this.setState('success');
this.stampMark('approved');
this.opts.onSuccess?.(code);
}
reject(code, message) {
this.message = message;
this.setState('error');
this.stampMark('denied');
this.input.select();
this.opts.onError?.(code, message);
}
url(file) { return this.assetMap ? this.assetMap[file] : this.base + file; }
// Every delayed step goes through here, so a frame-by-frame replay (the reel) can run them on its own clock.
later(fn, ms) { return setTimeout(fn, ms); }
cancelLater(id) { clearTimeout(id); }
preload() {
for (const k in MARKS) for (const f of [MARKS[k].ink, MARKS[k].tool]) {
const img = new Image();
img.src = this.url(f);
img.decode?.().catch(() => {});
}
}
// viewBox units → percentages of the field, so marks scale with the strip
px(x, y) { return { left: ((x + PAD_X) / this.vb.w) * 100 + '%', top: ((y + PAD_TOP) / this.vb.h) * 100 + '%' }; }
pw(w) { return (w / this.vb.w) * 100 + '%'; }
// Somewhere on the strip, at a hand-placed angle: the whole (rotated) mark stays on the stamps,
// give or take a few units, and it doesn't land right where the last one did.
place(m) {
const h = m.w * m.aspect;
let best = null;
for (let tries = 0; tries < 6; tries++) {
const rot = (Math.random() * 2 - 1) * m.tilt;
const r = (rot * Math.PI) / 180, c = Math.abs(Math.cos(r)), s = Math.abs(Math.sin(r));
const hx = (m.w * c + h * s) / 2, hy = (m.w * s + h * c) / 2;
const span = (lo, hi) => (lo <= hi ? lo + Math.random() * (hi - lo) : (lo + hi) / 2);
const x = span(hx - OVERHANG, this.stripW - hx + OVERHANG);
const y = span(hy - OVERHANG, H - hy + OVERHANG);
const far = !this.lastSpot || Math.abs(x - this.lastSpot.x) > this.stripW * 0.22;
best = { x, y, rot };
if (far) break;
}
this.lastSpot = best;
return best;
}
// The signature moment. The hand stamp comes in from above, settles over its spot, presses down —
// the ink takes at the moment of contact, the strip gives a little — then lifts away and leaves the mark.
stampMark(kind) {
this.clearMarks(true);
const m = MARKS[kind];
const token = this.token;
const { x, y, rot } = this.place(m);
const still = reducedMotion.matches || this.disabled;
const ink = document.createElement('img');
ink.className = `sp-ink sp-ink-${kind}`;
ink.src = this.url(m.ink);
ink.alt = '';
ink.draggable = false;
Object.assign(ink.style, this.px(x - m.w / 2, y - (m.w * m.aspect) / 2), { width: this.pw(m.w), transform: `rotate(${rot}deg)` });
this.marks.appendChild(ink);
this.ink = ink;
if (still) {
ink.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, fill: 'backwards' });
if (this.soundOn && !this.disabled) this.sound.stampStill(kind);
return;
}
// the tool: its rubber face is centred on the spot; it turns about that face
const tw = m.toolW, th = tw * m.toolAspect;
const tool = document.createElement('div');
tool.className = `sp-tool sp-tool-${kind}`;
Object.assign(tool.style, this.px(x - tw / 2, y - th * m.face), { width: this.pw(tw), aspectRatio: `${1 / m.toolAspect}`, transformOrigin: `50% ${m.face * 100}%` });
const part = (cls) => {
const img = document.createElement('img');
img.className = cls;
img.src = this.url(m.tool);
img.alt = '';
img.draggable = false;
return img;
};
const shadow = part('sp-tool-shadow'), body = part('sp-tool-body');
tool.append(shadow, body);
this.marks.appendChild(tool);
this.tool = tool;
// sound on the audio clock, keyed to the same keyframes: swoop in, DUM on contact, unstick, swoop away
if (this.soundOn) this.sound.stamp(kind, { land: (STAMP_MS * CONTACT) / 1000, lift: (STAMP_MS * LIFT) / 1000, gone: STAMP_MS / 1000 });
const R = (deg) => `rotate(${rot + deg}deg)`;
const ease = { in: 'cubic-bezier(.5,0,.9,.45)', out: 'cubic-bezier(.2,.75,.3,1)', inOut: 'cubic-bezier(.55,0,.35,1)' };
// the tool: arrive and hover (bigger = nearer the eye), press, hold, lift away
body.animate(
[
{ offset: 0, opacity: 0, transform: `translate(26%, -58%) ${R(14)} scale(1.55)`, easing: ease.out },
{ offset: 0.33, opacity: 1, transform: `translate(0, 0) ${R(0)} scale(1.14)`, easing: ease.in },
{ offset: CONTACT, opacity: 1, transform: `translate(0, 0) ${R(0)} scale(0.975)`, easing: 'linear' },
{ offset: LIFT, opacity: 1, transform: `translate(0, 0) ${R(0)} scale(0.975)`, easing: ease.inOut },
{ offset: 1, opacity: 0, transform: `translate(-16%, -62%) ${R(-8)} scale(1.5)` },
],
{ duration: STAMP_MS, fill: 'forwards' },
);
// its shadow falls down and right of it, and tightens as it nears the paper
shadow.animate(
[
{ offset: 0, opacity: 0, transform: `translate(34%, -40%) ${R(14)} scale(1.5)`, easing: ease.out },
{ offset: 0.33, opacity: 0.32, transform: `translate(7%, 11%) ${R(0)} scale(1.1)`, easing: ease.in },
{ offset: CONTACT, opacity: 0.5, transform: `translate(1.5%, 3%) ${R(0)} scale(0.99)`, easing: 'linear' },
{ offset: LIFT, opacity: 0.5, transform: `translate(1.5%, 3%) ${R(0)} scale(0.99)`, easing: ease.inOut },
{ offset: 1, opacity: 0, transform: `translate(-4%, -30%) ${R(-8)} scale(1.45)` },
],
{ duration: STAMP_MS, fill: 'forwards' },
);
// the ink takes on contact: a touch of spread, then it sits down into the paper
const contact = STAMP_MS * CONTACT;
ink.animate(
[
{ opacity: 0, transform: `rotate(${rot}deg) scale(1.035)` },
{ opacity: 0.95, transform: `rotate(${rot}deg) scale(1.008)`, offset: 0.35 },
{ opacity: 1, transform: `rotate(${rot}deg) scale(1)` },
],
{ duration: 200, delay: contact - 10, easing: ease.out, fill: 'backwards' },
);
this.contactTimer = this.later(() => {
if (token !== this.token) return;
this.svg.animate([{ transform: 'scale(1, 1)' }, { transform: 'scale(1.003, 0.988)', offset: 0.3 }, { transform: 'scale(1, 1)' }], { duration: 200, easing: ease.out });
}, contact);
this.toolTimer = this.later(() => { if (this.tool === tool) { tool.remove(); this.tool = null; } }, STAMP_MS + 40);
}
// a fresh edit lifts the old verdict off the strip
clearMarks(instant) {
this.cancelLater(this.contactTimer);
if (this.tool) this.sound.cut(); // interrupted mid-move: silence the rest of it
this.cancelLater(this.toolTimer);
if (this.tool) { this.tool.remove(); this.tool = null; }
const ink = this.ink;
this.ink = null;
if (!ink) return;
if (instant || reducedMotion.matches) { ink.remove(); return; }
ink.getAnimations().forEach((a) => a.cancel());
ink.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: 'ease-out', fill: 'forwards' }).onfinish = () => ink.remove();
}
setState(s) {
this._state = s;
if (s !== 'verifying') { this.cancelLater(this.pendingTimer); this.root.classList.remove('sp-pending'); }
this.input.readOnly = s === 'verifying' || s === 'success';
this.input.setAttribute('aria-invalid', s === 'error' ? 'true' : 'false');
this.render();
}
render() {
const v = this.input.value;
this.stamps.forEach((s, i) => {
const d = v[i] || '';
if (s.num.textContent !== d) { s.num.textContent = d; s.wet.textContent = d; }
s.g.classList.toggle('is-filled', !!d);
});
const r = this.root.classList;
for (const s of ['idle', 'verifying', 'success', 'error']) r.toggle('sp-' + s, this._state === s);
r.toggle('sp-disabled', !!this.disabled);
const msg = {
idle: '',
verifying: 'Checking the postmark…',
success: 'Verified',
error: this.message || '',
}[this._state];
const icon = {
verifying: '<span class="sp-spin" aria-hidden="true"></span>',
success: '<svg class="sp-check" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7.2"/><path d="M4.8 8.3l2.2 2.2 4.3-4.6"/></svg>',
error: '<svg class="sp-alert" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.6"/><path d="M8 4.6v4"/><circle cx="8" cy="11.2" r=".4"/></svg>',
}[this._state] || '';
const html = msg ? `${icon}<span>${msg}</span>` : '';
if (this.status.dataset.key !== this._state + msg) {
this.status.dataset.key = this._state + msg;
this.status.innerHTML = html;
}
this.renderCaret();
}
// The active stamp: an inner ink outline plus a blinking guide (not colour alone).
// A selection outlines every selected stamp; a full or locked strip shows none.
renderCaret() {
const { input } = this;
const focused = document.activeElement === input && !this.locked() && this._state !== 'error';
const len = input.value.length;
let s = input.selectionStart ?? len, e = input.selectionEnd ?? len;
if (s === e && s >= this.n) s = e = -1;
this.stamps.forEach((st, i) => {
const active = focused && (s === e ? i === s : i >= s && i < e);
st.g.classList.toggle('is-active', active);
st.g.classList.toggle('is-caret', active && s === e);
});
this.root.classList.toggle('sp-focused', document.activeElement === input);
}
fill(code) {
if (this.disabled) return;
if (this._state === 'success' || this._state === 'verifying') this.reset();
const digits = String(code).replace(/\D/g, '').slice(0, this.n);
this.prev = this.input.value;
this.commit(digits, digits.length, 'fill');
}
reset() {
this.token++;
this.message = '';
this.input.value = '';
this.prev = '';
this.clearMarks(true);
this.setState('idle');
}
clear() { this.reset(); this.focus(); }
focus() { this.input.focus({ preventScroll: true }); this.input.setSelectionRange(this.input.value.length, this.input.value.length); this.sync(); }
setDisabled(on) {
this.disabled = !!on;
this.input.disabled = this.disabled;
this.render();
}
setSound(on) { this.soundOn = !!on; }
}
window.StampPin = StampPin;
})(); <!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Stamp PIN</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Gloock&family=Josefin+Sans:wght@300&family=Jost:wght@400;500;600&family=Playfair+Display:wght@800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="stamp-pin.css">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body {
display: grid;
place-items: center;
grid-template-columns: minmax(0, 520px);
justify-content: center;
padding: 48px 16px;
background: #f5f0e6;
font-family: "Jost", ui-sans-serif, system-ui, sans-serif;
color: #292722;
}
/* paper grain */
body::after {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: 0.16;
mix-blend-mode: multiply;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.45 0 0 0 0 0.4 0 0 0 0 0.3 0 0 0 0.9 0'/%3E%3C/filter%3E%3Crect width='200' height='200' filter='url(%23n)'/%3E%3C/svg%3E");
}
.page {
min-width: 0;
width: 100%;
max-width: 520px;
}
.eyebrow {
margin: 0 0 18px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.32em;
text-transform: uppercase;
color: #6b6257;
}
h1 {
margin: 0;
font: 400 clamp(40px, 12.4vw, 84px)/0.94 "Gloock", Georgia, serif;
letter-spacing: 0.005em;
text-transform: uppercase;
}
.sub {
margin: 14px 0 48px;
font: 300 clamp(18px, 5vw, 22px)/1.3 "Josefin Sans", ui-sans-serif, sans-serif;
}
#pin { max-width: 470px; width: calc(100% + 32px); margin: 0 auto; }
.hint {
margin: 26px 0 0;
text-align: center;
font-size: 12px;
letter-spacing: 0.14em;
color: #8a8074;
}
.hint b { font-weight: 500; color: #4f483f; letter-spacing: 0.24em; }
/* demo only: drives the component */
.demo {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px;
margin-top: 18px;
}
.demo button {
padding: 7px 10px;
border: 1px solid rgba(41, 39, 34, 0.2);
border-radius: 4px;
background: transparent;
color: #3d3830;
font: 500 10.5px/1 "Jost", ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.14em;
text-transform: uppercase;
cursor: pointer;
}
.demo button:hover { background: rgba(41, 39, 34, 0.06); }
.demo button:focus-visible { outline: 2px solid #292722; outline-offset: 2px; }
@media (max-width: 520px) {
#pin { margin: 0 -16px; }
}
</style>
</head>
<body>
<main class="page">
<p class="eyebrow">Verification</p>
<h1>Special<br>Delivery.</h1>
<p class="sub" id="pin-label">Four little stamps. One secure code.</p>
<div id="pin"></div>
<p class="hint">Try <b>4829</b> — anything else is returned to sender</p>
<div class="demo">
<button type="button" data-act="good">Autofill correct</button>
<button type="button" data-act="bad">Autofill wrong</button>
<button type="button" data-act="clear">Clear</button>
<button type="button" data-act="disable" aria-pressed="false">Disable</button>
<button type="button" data-act="sound" aria-pressed="true">Sound on</button>
</div>
</main>
<script src="stamp-pin.js"></script>
<script>
// A stand-in for the server: only the server decides whether a code is right.
const fakeServer = (code) => new Promise((done) => setTimeout(() => done(code === '4829'), 700));
const pin = new StampPin(document.getElementById('pin'), {
labelledBy: 'pin-label',
autofocus: true,
verify: fakeServer,
});
document.querySelector('.demo').addEventListener('click', (e) => {
const b = e.target.closest('button');
if (!b) return;
const act = b.dataset.act;
if (act === 'good') { pin.fill('4829'); pin.focus(); }
else if (act === 'bad') { pin.fill('4892'); pin.focus(); }
else if (act === 'clear') pin.clear();
else if (act === 'disable') {
const on = b.getAttribute('aria-pressed') !== 'true';
pin.setDisabled(on);
b.setAttribute('aria-pressed', on);
b.textContent = on ? 'Enable' : 'Disable';
} else if (act === 'sound') {
const on = b.getAttribute('aria-pressed') !== 'true';
pin.setSound(on);
b.setAttribute('aria-pressed', on);
b.textContent = on ? 'Sound on' : 'Sound off';
}
});
// ?code=4829 shows a still state (a full code is checked: 4829 is approved, anything else denied)
const still = new URLSearchParams(location.search).get('code');
if (still) pin.fill(still);
</script>
</body>
</html> All 7 files in the zip
-
assets/approved.webp -
assets/denied.webp -
assets/tool-rect.webp -
assets/tool-round.webp -
index.html -
stamp-pin.css -
stamp-pin.js