Flow

Screens / Login / Verify

Vault dial

Two-step verification as a bank vault: each digit of the code spins the combination dial, and a correct code retracts the bolts and swings the door open onto warm light.

A working screen: type, make mistakes, sign in. Open it full screen.

The idea

Your code is the combination; the account is the vault.

Palette

#0e1114
#2b3239
#a7b2bc
#c9a24a
#f0d88f
#ffbe62

Type and motion

IBM Plex Mono throughout: 700 for the headline, engraved digits and brass plates, 400–600 for copy (fallback ui-monospace / SF Mono / Menlo).

The dial spins to each digit, alternating direction like a real lock; on success the bolts retract one by one, the wheel turns and the door swings on its hinge (3D rotateY); on error the dial shakes and the red lamp blinks.

Make it yours

  • A hotel-room keycard reader whose LED strip fills one segment per digit and clicks green, for a travel or hospitality app.
  • An old rotary phone where each digit dials and the line connects on the last one, for a retro communications brand.
  • A space-station airlock where each digit pressurizes one chamber and the hatch hisses open, for a gaming or developer tool.

Why it's engaging

Typing a code becomes cracking a safe: brass number wheels roll in, the dial spins to every digit, and the heavy door swinging open is a real payoff.

Why it also works better

It is ONE real input with autocomplete="one-time-code", so SMS autofill and pasting "482 913" or "482-913" just work, it submits itself at six digits, and Resend counts down in plain sight.

Tested code

One self-contained HTML file, no libraries. Your AI gets it from Flow's get_pattern tool.

Show the code
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Vault dial verification | Flow pattern</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=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <!-- Flow login pattern: Vault dial (https://flow.donta.io/screens/login/vault-dial) Two-step verification as a bank vault. Every digit of the one-time code spins the combination dial to that number; when all six are in, the code submits itself, the bolts retract one by one and the door swings open onto warm light. A wrong code shakes the dial and blinks the red lamp. Under the art it's one real input with autocomplete="one-time-code", so SMS autofill, paste ("123 456", "123-456") and password managers all work. The vault is decorative (aria-hidden). Demo: any 6 digits verify; 000000 shows the error. --> <style> :root { --wall-1: #0e1114; --wall-2: #1a1f25; --steel-1: #2b3239; --steel-2: #5d6873; --steel-3: #a7b2bc; --brass-1: #8a6a2a; --brass-2: #c9a24a; --brass-3: #f0d88f; --warm: #ffbe62; --ink: #eef1f4; --muted: #98a3ad; --err: #ff7a6b; --ok: #7ee3a8; --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; --sans: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace; } * { box-sizing: border-box; } html, body { margin: 0; min-height: 100%; } body { min-height: 100vh; font-family: var(--sans); color: var(--ink); background: radial-gradient(60% 70% at 32% 50%, rgba(255, 190, 98, 0.07), transparent 70%), repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.018) 0 1px, transparent 1px 120px), repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.018) 0 1px, transparent 1px 120px), linear-gradient(160deg, var(--wall-2), var(--wall-1) 70%); overflow-x: hidden; } .scene { min-height: 100vh; max-width: 1240px; margin: 0 auto; padding: 32px clamp(20px, 5vw, 64px); display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(340px, 0.95fr); gap: clamp(24px, 5vw, 72px); align-items: center; } /* ── Vault ─────────────────────────────────── */ .vault { position: relative; width: min(560px, 100%); aspect-ratio: 1; justify-self: center; perspective: 1600px; } .vault > * { position: absolute; inset: 0; } .frame svg, .door svg { width: 100%; height: 100%; display: block; overflow: visible; } .interior { inset: 7%; border-radius: 50%; background: radial-gradient(circle at 50% 46%, #fff1c9 0%, #ffcf7a 18%, #e08a2e 42%, #6b3410 72%, #2a1406 100%); box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.75); display: grid; place-items: center start; padding-left: 16%; opacity: 0; transition: opacity 0.6s ease 0.3s; } .interior span { font: 700 clamp(22px, 3.4vw, 34px)/1 var(--mono); color: #3a1b05; letter-spacing: 0.02em; text-shadow: 0 1px 0 rgba(255, 240, 200, 0.6); translate: 0 10px; opacity: 0; transition: opacity 0.5s 1.1s, translate 0.6s 1.1s cubic-bezier(.3,1.4,.5,1); } .door { inset: 6%; transform-origin: 100% 50%; transition: transform 1.3s cubic-bezier(.55,.05,.25,1) 0.75s; z-index: 2; } .vault.open .interior { opacity: 1; } .vault.open .interior span { opacity: 1; translate: 0 0; } .vault.open .door { transform: rotateY(-72deg); } .dial { transform-box: view-box; transform-origin: 300px 300px; transition: transform 0.65s cubic-bezier(.25,.9,.3,1.15); } .wheel { transform-box: view-box; transform-origin: 300px 300px; transform: rotate(45deg); transition: transform 1s cubic-bezier(.4,0,.2,1); } .vault.pending .wheel { transform: rotate(80deg); transition-duration: 0.7s; } .vault.unlocked .wheel { transform: rotate(-135deg); } .bolt { transition: transform 0.35s cubic-bezier(.5,0,.3,1); } .vault.unlocked .bolt { transform: translate(var(--bx), var(--by)); } .vault.shake .dial-wrap { animation: dial-shake 0.45s ease; } .dial-wrap { transform-box: view-box; transform-origin: 300px 300px; } @keyframes dial-shake { 20%, 60% { transform: rotate(-6deg); } 40%, 80% { transform: rotate(6deg); } } .lamp { fill: #3a2a1a; transition: fill 0.2s; } .lamp-glow { opacity: 0; transition: opacity 0.2s; } .vault.pending .lamp { fill: #ffb347; animation: pulse 0.6s ease-in-out infinite alternate; } .vault.pending .lamp-glow { opacity: 0.6; fill: #ffb347; } .vault.error .lamp { fill: #ff4d3d; animation: blink 0.35s steps(1) 4; } .vault.error .lamp-glow { opacity: 0.8; fill: #ff4d3d; animation: blink 0.35s steps(1) 4; } .vault.unlocked .lamp { fill: #49e08b; } .vault.unlocked .lamp-glow { opacity: 0.8; fill: #49e08b; } @keyframes pulse { to { opacity: 0.5; } } @keyframes blink { 50% { opacity: 0.15; } } /* ── Panel ─────────────────────────────────── */ .panel { position: relative; width: min(420px, 100%); justify-self: center; padding: 34px 32px 26px; border-radius: 18px; background: linear-gradient(170deg, #20262d, #14181d 70%); border: 1px solid rgba(240, 216, 143, 0.28); box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.07), inset 0 0 0 6px rgba(0, 0, 0, 0.18); display: grid; gap: 18px; } .screw { position: absolute; width: 10px; height: 10px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, var(--brass-3), var(--brass-1)); box-shadow: 0 1px 1px rgba(0,0,0,.6); } .screw::after { content: ""; position: absolute; left: 2px; right: 2px; top: 4px; height: 1.5px; background: rgba(60, 40, 10, 0.8); transform: rotate(35deg); } .screw.tl { top: 12px; left: 12px; } .screw.tr { top: 12px; right: 12px; } .screw.bl { bottom: 12px; left: 12px; } .screw.br { bottom: 12px; right: 12px; } .plate { justify-self: start; margin: 0; padding: 6px 12px; border-radius: 6px; background: linear-gradient(180deg, var(--brass-3), var(--brass-2) 60%, var(--brass-1)); color: #2b1d05; font: 700 12px/1 var(--mono); letter-spacing: 0.14em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 2px 6px rgba(0, 0, 0, 0.4); } h1 { margin: -4px 0 0; font: 700 clamp(26px, 2.8vw, 32px)/1.1 var(--mono); letter-spacing: -0.02em; } .sub { margin: -6px 0 0; color: var(--muted); font-size: 15px; line-height: 1.55; text-wrap: pretty; } .sub b { color: var(--ink); font-weight: 600; white-space: nowrap; } .code { position: relative; } .code input { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0; border: 0; padding: 0; font-size: 16px; color: transparent; caret-color: transparent; background: transparent; z-index: 2; cursor: text; } .wheels { display: grid; grid-template-columns: repeat(3, 1fr) 14px repeat(3, 1fr); gap: 8px; align-items: center; } .wheels i { height: 2px; background: rgba(240, 216, 143, 0.35); border-radius: 2px; } .slot { position: relative; height: 68px; border-radius: 10px; overflow: hidden; background: linear-gradient(180deg, #07090b 0%, #1d2329 18%, #2a3138 50%, #1d2329 82%, #07090b 100%); border: 1.5px solid rgba(240, 216, 143, 0.32); box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.8), 0 1px 0 rgba(255, 255, 255, 0.05); display: grid; place-items: center; transition: border-color 0.15s, box-shadow 0.15s; } .slot b { font: 700 32px/1 var(--mono); color: var(--brass-3); text-shadow: 0 1px 0 #000, 0 0 12px rgba(240, 216, 143, 0.25); translate: 0 0; } .slot b.roll { animation: roll 0.32s cubic-bezier(.3,1.5,.5,1); } @keyframes roll { 0% { translate: 0 -34px; opacity: 0; } 100% { translate: 0 0; opacity: 1; } } .code.focused .slot.current { border-color: var(--brass-3); box-shadow: inset 0 2px 6px rgba(0,0,0,.8), 0 0 0 3px rgba(240, 216, 143, 0.25); } .code.focused .slot.current::after { content: ""; position: absolute; bottom: 14px; width: 18px; height: 3px; border-radius: 2px; background: var(--brass-3); animation: caret 1s steps(1) infinite; } @keyframes caret { 50% { opacity: 0; } } .code.error .slot { border-color: var(--err); } .code.ok .slot { border-color: var(--ok); } .code.ok .slot b { color: #c9f5da; } .btn { min-height: 52px; border: 0; border-radius: 12px; cursor: pointer; background: linear-gradient(180deg, var(--brass-3), var(--brass-2) 55%, #a8832f); color: #241703; font: 700 16px/1 var(--mono); letter-spacing: 0.02em; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.55), inset 0 -2px 0 rgba(80, 50, 0, 0.35), 0 10px 24px rgba(201, 162, 74, 0.2); } .btn:hover { filter: brightness(1.06); } .btn:disabled { cursor: progress; opacity: 0.8; } .btn:focus-visible, .link:focus-visible { outline: 3px solid var(--brass-3); outline-offset: 2px; } .msg { min-height: 22px; margin: -4px 0 0; font-size: 14px; line-height: 1.45; font-weight: 500; } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; font-size: 14px; color: var(--muted); border-top: 1px solid rgba(255, 255, 255, 0.08); padding-top: 6px; } .link { border: 0; background: none; min-height: 44px; padding: 0 4px; color: var(--brass-3); font: 600 14px/1 var(--mono); cursor: pointer; text-decoration: underline; text-underline-offset: 4px; } .link:disabled { color: var(--muted); text-decoration: none; cursor: default; font-weight: 500; } .hint { margin: -6px 0 0; font-size: 12.5px; color: #7d8892; line-height: 1.5; } @media (max-width: 860px) { .scene { grid-template-columns: 1fr; gap: 20px; padding: 20px 18px 36px; align-items: start; } .vault { width: min(320px, 82vw); } .slot { height: 58px; } .slot b { font-size: 26px; } .panel { padding: 30px 22px 22px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } } </style> </head> <body> <main class="scene"> <div class="vault" id="vault" aria-hidden="true"> <!-- frame set into the wall --> <div class="frame"> <svg viewBox="0 0 600 600"> <defs> <radialGradient id="frameG" cx="0.5" cy="0.5" r="0.5"> <stop offset="0.8" stop-color="#0a0c0f" /> <stop offset="0.88" stop-color="#3b434c" /> <stop offset="0.94" stop-color="#7f8b96" /> <stop offset="1" stop-color="#23292f" /> </radialGradient> </defs> <circle cx="300" cy="300" r="298" fill="url(#frameG)" /> <circle cx="300" cy="300" r="298" fill="none" stroke="rgba(0,0,0,.6)" stroke-width="2" /> <g fill="#05070a"> <rect x="292" y="10" width="16" height="28" rx="3" /><rect x="292" y="562" width="16" height="28" rx="3" /> <rect x="10" y="292" width="28" height="16" rx="3" /><rect x="562" y="292" width="28" height="16" rx="3" /> <rect x="88" y="88" width="16" height="28" rx="3" transform="rotate(-45 96 102)" /><rect x="496" y="88" width="16" height="28" rx="3" transform="rotate(45 504 102)" /> <rect x="88" y="484" width="16" height="28" rx="3" transform="rotate(45 96 498)" /><rect x="496" y="484" width="16" height="28" rx="3" transform="rotate(-45 504 498)" /> </g> </svg> </div> <div class="interior"><span>You're in</span></div> <div class="door"> <svg viewBox="0 0 600 600"> <defs> <radialGradient id="doorG" cx="0.42" cy="0.38" r="0.75"> <stop offset="0" stop-color="#c4cdd5" /> <stop offset="0.45" stop-color="#7d8994" /> <stop offset="0.85" stop-color="#3d464f" /> <stop offset="1" stop-color="#262c32" /> </radialGradient> <radialGradient id="brassG" cx="0.4" cy="0.35" r="0.8"> <stop offset="0" stop-color="#fbe7a6" /> <stop offset="0.5" stop-color="#c9a24a" /> <stop offset="1" stop-color="#6e5220" /> </radialGradient> <radialGradient id="faceG" cx="0.45" cy="0.4" r="0.7"> <stop offset="0" stop-color="#2e353c" /> <stop offset="1" stop-color="#0d1013" /> </radialGradient> <linearGradient id="boltG" x1="0" y1="0" x2="1" y2="0"> <stop offset="0" stop-color="#5d6873" /><stop offset="0.5" stop-color="#d6dde3" /><stop offset="1" stop-color="#5d6873" /> </linearGradient> <radialGradient id="rivetG" cx="0.35" cy="0.3" r="0.7"> <stop offset="0" stop-color="#e3e8ec" /><stop offset="1" stop-color="#4b545d" /> </radialGradient> </defs> <!-- bolts (slide inward when unlocked) --> <g> <rect class="bolt" style="--bx:0px;--by:34px" x="288" y="-6" width="24" height="56" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:0px;--by:-34px" x="288" y="550" width="24" height="56" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:34px;--by:0px" x="-6" y="288" width="56" height="24" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:-34px;--by:0px" x="550" y="288" width="56" height="24" rx="4" fill="url(#boltG)" /> <g transform="rotate(45 300 300)"> <rect class="bolt" style="--bx:0px;--by:34px" x="288" y="-6" width="24" height="56" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:0px;--by:-34px" x="288" y="550" width="24" height="56" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:34px;--by:0px" x="-6" y="288" width="56" height="24" rx="4" fill="url(#boltG)" /> <rect class="bolt" style="--bx:-34px;--by:0px" x="550" y="288" width="56" height="24" rx="4" fill="url(#boltG)" /> </g> </g> <!-- door slab --> <circle cx="300" cy="300" r="272" fill="url(#doorG)" /> <g fill="none" stroke="#fff" stroke-opacity="0.045"> <circle cx="300" cy="300" r="250" /><circle cx="300" cy="300" r="236" /><circle cx="300" cy="300" r="222" /><circle cx="300" cy="300" r="208" /> <circle cx="300" cy="300" r="194" /><circle cx="300" cy="300" r="180" /><circle cx="300" cy="300" r="166" /><circle cx="300" cy="300" r="152" /> </g> <circle cx="300" cy="300" r="272" fill="none" stroke="#1a1f24" stroke-width="6" /> <circle cx="300" cy="300" r="258" fill="none" stroke="rgba(255,255,255,.12)" stroke-width="2" /> <!-- rivets --> <g fill="url(#rivetG)" id="rivets"></g> <!-- brand plate + lamp --> <rect x="236" y="74" width="128" height="30" rx="5" fill="url(#brassG)" /> <text x="300" y="95" text-anchor="middle" style="font-family: var(--mono)" font-weight="700" font-size="14" letter-spacing="3" fill="#2b1d05">VAULT 07</text> <circle class="lamp-glow" cx="300" cy="134" r="16" filter="blur(6px)" /> <circle cx="300" cy="134" r="9" fill="#111" /> <circle class="lamp" cx="300" cy="134" r="6.5" /> <!-- spoked handle wheel --> <g class="wheel"> <g stroke="#9aa6b1" stroke-width="12" stroke-linecap="round"> <line x1="300" y1="300" x2="300" y2="122" /><line x1="300" y1="300" x2="478" y2="300" /> <line x1="300" y1="300" x2="300" y2="478" /><line x1="300" y1="300" x2="122" y2="300" /> </g> <g stroke="#e3e8ec" stroke-width="3" stroke-linecap="round" opacity="0.5"> <line x1="296" y1="292" x2="296" y2="126" /><line x1="306" y1="296" x2="474" y2="296" /> </g> <g fill="url(#brassG)" stroke="#5c4418" stroke-width="2"> <circle cx="300" cy="114" r="16" /><circle cx="486" cy="300" r="16" /><circle cx="300" cy="486" r="16" /><circle cx="114" cy="300" r="16" /> </g> </g> <!-- combination dial --> <g class="dial-wrap"> <circle cx="300" cy="300" r="112" fill="#0b0d10" opacity="0.5" transform="translate(0 6)" /> <circle cx="300" cy="300" r="110" fill="url(#brassG)" /> <circle cx="300" cy="300" r="98" fill="#4a3714" /> <g class="dial" id="dial"> <circle cx="300" cy="300" r="95" fill="url(#faceG)" /> <g id="ticks" stroke="#d8c48a"></g> <g id="nums" fill="#f0d88f" style="font-family: var(--mono)" font-size="20" font-weight="700" text-anchor="middle"></g> <circle cx="300" cy="300" r="40" fill="url(#brassG)" /> <circle cx="300" cy="300" r="30" fill="#1a1f24" /> <g stroke="#8a6a2a" stroke-width="4" stroke-linecap="round"> <line x1="300" y1="276" x2="300" y2="324" /> </g> </g> <!-- fixed indicator --> <path d="M300 196 L290 178 H310Z" fill="#ff7a4a" stroke="#3a1b05" stroke-width="1.5" /> </g> </svg> </div> </div> <form class="panel" id="form" novalidate> <span class="screw tl"></span><span class="screw tr"></span><span class="screw bl"></span><span class="screw br"></span> <p class="plate">Two-step check</p> <h1>Enter your code</h1> <p class="sub">We texted a code to <b>(949) ••• ••42</b>. Each digit you type turns the dial.</p> <div class="code" id="code"> <label for="otp" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap">6-digit code</label> <input id="otp" name="otp" inputmode="numeric" autocomplete="one-time-code" maxlength="6" aria-describedby="msg"> <div class="wheels" aria-hidden="true"> <span class="slot"><b></b></span><span class="slot"><b></b></span><span class="slot"><b></b></span> <i></i> <span class="slot"><b></b></span><span class="slot"><b></b></span><span class="slot"><b></b></span> </div> </div> <button class="btn" id="verify" type="submit">Unlock</button> <p class="msg" id="msg" role="status" aria-live="polite"></p> <div class="row"> <span>Didn't get it?</span> <button class="link" id="resend" type="button" disabled>Resend in 30s</button> </div> <p class="hint">Demo: any 6 digits unlock; 000000 shows the error. Try pasting "482 913".</p> </form> </main> <script> (() => { // Build dial ticks, numbers and rivets. const NS = "http://www.w3.org/2000/svg"; const ticks = document.getElementById("ticks"); for (let i = 0; i < 50; i++) { const a = (i / 50) * Math.PI * 2; const long = i % 5 === 0; const r1 = long ? 70 : 78, r2 = 90; const l = document.createElementNS(NS, "line"); l.setAttribute("x1", 300 + Math.sin(a) * r1); l.setAttribute("y1", 300 - Math.cos(a) * r1); l.setAttribute("x2", 300 + Math.sin(a) * r2); l.setAttribute("y2", 300 - Math.cos(a) * r2); l.setAttribute("stroke-width", long ? 3 : 1.4); l.setAttribute("opacity", long ? 1 : 0.6); ticks.append(l); } const nums = document.getElementById("nums"); for (let d = 0; d < 10; d++) { const a = (d / 10) * Math.PI * 2; const t = document.createElementNS(NS, "text"); t.setAttribute("x", 300 + Math.sin(a) * 56); t.setAttribute("y", 300 - Math.cos(a) * 56 + 7); t.setAttribute("transform", `rotate(${d * 36} ${300 + Math.sin(a) * 56} ${300 - Math.cos(a) * 56})`); t.textContent = d; nums.append(t); } const rivets = document.getElementById("rivets"); for (let i = 0; i < 24; i++) { const a = (i / 24) * Math.PI * 2 + Math.PI / 24; const c = document.createElementNS(NS, "circle"); c.setAttribute("cx", 300 + Math.sin(a) * 244); c.setAttribute("cy", 300 - Math.cos(a) * 244); c.setAttribute("r", 6); rivets.append(c); } const vault = document.getElementById("vault"); const dial = document.getElementById("dial"); const wrap = document.getElementById("code"); const input = document.getElementById("otp"); const slots = [...wrap.querySelectorAll(".slot")]; const form = document.getElementById("form"); const verify = document.getElementById("verify"); const msg = document.getElementById("msg"); const resend = document.getElementById("resend"); const bolts = [...document.querySelectorAll(".bolt")]; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; let angle = 0, prev = "", lastSubmitted = ""; // Turn the dial to digit d, alternating direction like a real combination lock. function spinTo(d, i) { const target = -d * 36; const dir = i % 2 === 0 ? -1 : 1; let a = target; while ((a - angle) * dir < 180) a += 360 * dir; while ((a - angle) * dir > 540) a -= 360 * dir; angle = a; dial.style.transform = `rotate(${angle}deg)`; } function render() { // Be liberal in what we accept: keep only digits ("123 456", "123-456" both work). const v = input.value.replace(/\D/g, "").slice(0, 6); if (v !== input.value) input.value = v; slots.forEach((s, i) => { const b = s.querySelector("b"); const ch = v[i] || ""; if (ch !== b.textContent) { b.textContent = ch; if (ch && !reduced) { b.classList.remove("roll"); void b.offsetWidth; b.classList.add("roll"); } } s.classList.toggle("current", i === Math.min(v.length, 5) && v.length < 6); }); if (v.length > prev.length) spinTo(Number(v[v.length - 1]), v.length - 1); prev = v; wrap.classList.remove("error"); vault.classList.remove("error"); if (v.length === 6 && v !== lastSubmitted) { lastSubmitted = v; form.requestSubmit(); } if (v.length < 6) lastSubmitted = ""; } input.addEventListener("input", () => { if (!vault.classList.contains("unlocked")) { msg.className = "msg"; msg.textContent = ""; } render(); }); input.addEventListener("focus", () => { wrap.classList.add("focused"); render(); }); input.addEventListener("blur", () => wrap.classList.remove("focused")); input.addEventListener("keyup", () => input.setSelectionRange(input.value.length, input.value.length)); const wait = (ms) => new Promise((r) => setTimeout(r, ms)); form.addEventListener("submit", async (e) => { e.preventDefault(); if (vault.classList.contains("unlocked")) return; const v = input.value; if (v.length < 6) { msg.className = "msg err"; msg.textContent = `Enter all 6 digits. ${6 - v.length} to go.`; input.focus(); return; } verify.disabled = true; verify.textContent = "Checking…"; vault.classList.add("pending"); await wait(800); // your real verification request goes here vault.classList.remove("pending"); verify.disabled = false; verify.textContent = "Unlock"; if (v === "000000") { wrap.classList.add("error"); vault.classList.remove("shake", "error"); void vault.offsetWidth; vault.classList.add("shake", "error"); msg.className = "msg err"; msg.textContent = "That code didn't open it. Check the newest text, or send a new code."; input.focus(); input.select(); return; } wrap.classList.add("ok"); msg.className = "msg ok"; msg.textContent = "Verified. You're signed in."; verify.textContent = "Unlocked"; verify.disabled = true; // Bolts retract one by one, then the door swings open. bolts.forEach((b, i) => { b.style.transitionDelay = reduced ? "0s" : `${i * 70}ms`; }); vault.classList.add("unlocked"); await wait(reduced ? 0 : 200); vault.classList.add("open"); }); // Resend: a visible countdown instead of a hidden link. let t; function countdown(sec) { clearInterval(t); resend.disabled = true; const tick = () => { resend.textContent = sec > 0 ? `Resend in ${sec}s` : "Send a new code"; if (sec-- <= 0) { resend.disabled = false; clearInterval(t); } }; tick(); t = setInterval(tick, 1000); } resend.addEventListener("click", () => { msg.className = "msg ok"; msg.textContent = "New code sent."; input.value = ""; prev = ""; render(); input.focus(); countdown(30); }); countdown(30); render(); })(); </script> </body> </html>

Guardrails

  • The six slots and the vault are aria-hidden; screen readers get one labelled input and announced messages.
  • The door opens only after the code verifies; under reduced motion the bolts and door jump straight to their final state.
  • Never gate sign-in behind the interaction: the form works the moment the page loads, and any focus or typing skips the show.
  • A real <form> with <label>s, type="email"/"password", and autocomplete="username" + "current-password" (or "new-password" on sign-up, "one-time-code" for codes) so password managers, passkeys and SMS autofill work.
  • Keyboard path: Tab moves email, password, submit in order; Enter submits.
  • Errors in plain words next to the field, announced (aria-live), with focus moved to the field to fix. Don't clear what they typed.
  • Answer the submit within 100ms (pending state, button disabled while the request runs).
  • Respect prefers-reduced-motion: the delight becomes a still state, never a blocker.
  • Any character or effect is decorative: aria-hidden, and text contrast never depends on it.