№ 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.

♪ Plays sound — click inside to start
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; } }
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