Flow

Screens / Login / Sign in

Key rack

A boutique-hotel front desk at night where each way to sign in is a brass key on the rack, and the desk remembers which key you took last time.

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

The idea

Signing in is checking in: your account is a room, and the front desk keeps your key waiting.

Palette

#24130f
#5a3420
#d8ae5e
#f6edd9
#8a2b22
#2a1a10

Type and motion

DM Serif Display (fallback Georgia) for the hotel sign, room numbers and card headline; system sans for labels and controls, with small-caps tracking on kickers.

Keys swing on their hooks with damped pendulum physics, the bell rocks when you return, and a chosen key lifts and slides toward you; all disabled under reduced motion.

Make it yours

  • A coat-check counter with numbered tickets, where the stub you kept last time is pinned to the front.
  • A marina dock board where each provider is a boat at its berth and yours is the one with its lights on.
  • A library card catalogue where your drawer is already pulled out with a brass label plate.

Why it's engaging

Returning visitors see their cubby light up and their key swing forward with a "Last used" tag while the desk bell rings, and choosing a key slides it off its hook toward you.

Why it also works better

It ends the "was it Google or GitHub?" guess that creates duplicate accounts: the last method becomes one big "Continue with …" button on the registration card.

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>Key rack login | Flow pattern</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&display=swap"> <!-- Flow login pattern: Key rack (https://flow.donta.io/screens/login/key-rack) A boutique-hotel front desk at night. Each way to sign in is a brass key on the rack behind the desk. The desk remembers which key you took last time on this device: that cubby lights up, its key swings forward with a "Last used" tag, the bell rings, and the registration card greets you with one big "Continue with …" button. Picking a key slides it off its hook toward you. Returning people stop guessing which provider they used, which is how duplicate accounts happen. Only the method and a masked hint are stored. Demo: any key signs in; the Email key opens a form (any email, 8+ character password; "wrongpass" shows the error). Use "Simulate a return visit". --> <style> :root { --wall: #24130f; --wood: #5a3420; --wood-hi: #7b4a2c; --wood-lo: #3a2013; --cubby: #170b07; --brass: #d8ae5e; --brass-hi: #f5dc9e; --brass-lo: #8c6a2b; --card: #f6edd9; --card-ink: #2a1a10; --card-muted: #6d5a49; --card-line: #d9c8a6; --oxblood: #8a2b22; --oxblood-ink: #fff4e4; --err: #b3261e; --ok: #1d6b4c; --serif: "DM Serif Display", "Didot", "Bodoni 72", "Iowan Old Style", Georgia, serif; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; } * { box-sizing: border-box; } html, body { margin: 0; min-height: 100%; } body { min-height: 100vh; color: var(--oxblood-ink); overflow-x: hidden; background: radial-gradient(ellipse 50% 40% at 50% 8%, rgba(255, 196, 120, .22), transparent 70%), repeating-linear-gradient(90deg, rgba(0,0,0,.0) 0 120px, rgba(0,0,0,.18) 120px 122px), linear-gradient(180deg, #2e1813, var(--wall)); } .hall { display: grid; justify-items: center; padding: 20px 16px 0; min-height: 100vh; grid-template-rows: auto auto auto 1fr; } .sign { display: grid; justify-items: center; gap: 4px; margin-bottom: 18px; } .sign b { font: 400 13px/1 var(--serif); letter-spacing: .4em; text-transform: uppercase; color: var(--brass); } .sign span { font-size: 11px; letter-spacing: .3em; text-transform: uppercase; color: #b8957a; } .pendant { width: 2px; height: 30px; background: #8c6a2b; margin: -28px auto 0; } .lamp-shade { width: 74px; height: 26px; margin: 0 auto 12px; border-radius: 40px 40px 6px 6px; background: linear-gradient(180deg, #e8c27a, #a77a33); box-shadow: 0 20px 70px 30px rgba(255, 190, 110, .2); } /* ── Rack ───────────────────────────────── */ .rack { width: min(820px, 100%); padding: 14px; border-radius: 6px; background: linear-gradient(180deg, var(--wood-hi), var(--wood) 30%, var(--wood-lo)); box-shadow: 0 30px 60px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,230,190,.25); display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 10px; align-items: start; } .cubby { position: relative; height: 0; padding-bottom: 112%; border-radius: 3px; overflow: visible; background: linear-gradient(180deg, #0f0604, var(--cubby) 30%, #24120b); box-shadow: inset 0 10px 18px rgba(0,0,0,.75), inset 0 -2px 0 rgba(255,220,170,.08), 0 1px 0 rgba(255,220,170,.15); display: grid; justify-items: center; align-content: start; } .cubby .plate { position: absolute; left: 50%; bottom: -1px; translate: -50% 50%; z-index: 2; font: 400 10px/1 var(--serif); letter-spacing: .12em; color: #2b1a0c; background: linear-gradient(180deg, var(--brass-hi), var(--brass)); padding: 3px 7px; border-radius: 2px; box-shadow: 0 1px 2px rgba(0,0,0,.5); } .cubby.lit { background: radial-gradient(ellipse 80% 70% at 50% 20%, rgba(255, 200, 120, .55), rgba(255, 170, 80, .12) 60%, transparent), linear-gradient(180deg, #2a140b, #3a1d0e); } /* decorative contents */ .env { position: absolute; bottom: 10%; width: 62%; height: 34%; background: #e9dcc0; border-radius: 2px; rotate: -4deg; box-shadow: 0 2px 4px rgba(0,0,0,.5); } .env::after { content: ""; position: absolute; inset: 0; background: linear-gradient(160deg, transparent 49%, #c9b48d 50%, transparent 51%), linear-gradient(20deg, transparent 49%, #c9b48d 50%, transparent 51%); } .env.b { rotate: 6deg; bottom: 16%; width: 54%; background: #d8c9a8; } .env.c { background: #c99a7a; rotate: 2deg; } .hook { position: absolute; left: 50%; top: 9%; translate: -50% 0; width: 8px; height: 8px; border-radius: 50%; background: radial-gradient(circle at 35% 35%, var(--brass-hi), var(--brass-lo)); box-shadow: 0 1px 2px #000; } /* keys are the buttons */ .key { position: absolute; top: 7%; left: 50%; translate: -50% 0; width: 76%; height: 80%; border: 0; padding: 0; background: none; cursor: pointer; color: inherit; transform-origin: 50% 6%; transition: filter .2s; display: grid; justify-items: center; align-content: start; } .key svg { width: 100%; height: auto; overflow: visible; filter: drop-shadow(0 6px 6px rgba(0,0,0,.55)); } .key:hover svg, .key:focus-visible svg { filter: drop-shadow(0 6px 6px rgba(0,0,0,.55)) drop-shadow(0 0 10px rgba(255, 210, 140, .55)); } .key:focus-visible { outline: 2px solid var(--brass-hi); outline-offset: 4px; border-radius: 6px; } .key .tag { position: absolute; top: 22%; right: -26%; rotate: 10deg; white-space: nowrap; font-size: 10px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; background: var(--card); color: var(--oxblood); padding: 3px 6px; border-radius: 2px; box-shadow: 0 2px 4px rgba(0,0,0,.4); display: none; } .key.last .tag { display: block; } .key.last { animation: swing 2.2s cubic-bezier(.3,.6,.4,1) both; } @keyframes swing { 0% { rotate: 0deg; } 12% { rotate: 16deg; } 30% { rotate: -11deg; } 48% { rotate: 7deg; } 66% { rotate: -4deg; } 82% { rotate: 2deg; } 100% { rotate: 0deg; } } .key.taken { animation: take .8s cubic-bezier(.3,.7,.3,1) forwards; z-index: 5; } @keyframes take { 0% { translate: -50% 0; scale: 1; } 25% { translate: -50% -12px; scale: 1.02; rotate: -5deg; } 100% { translate: -50% 16%; scale: 1.28; rotate: 0deg; } } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } /* ── Desk ───────────────────────────────── */ .desk { position: relative; width: 100%; display: grid; justify-items: center; align-content: start; margin-top: 26px; padding-bottom: 40px; } .desk::before { /* the counter */ content: ""; position: absolute; left: 0; right: 0; top: 70px; bottom: 0; z-index: -1; background: linear-gradient(180deg, var(--brass-hi), var(--brass) 4px, var(--brass-lo) 7px, transparent 7px), repeating-linear-gradient(90deg, transparent 0 160px, rgba(0,0,0,.18) 160px 162px), linear-gradient(180deg, #6a3d24, #3d2214 60%, #22120a); box-shadow: 0 -10px 30px rgba(0,0,0,.4); } .bell { position: absolute; top: 34px; left: calc(50% + 250px); width: 64px; height: 44px; transform-origin: 50% 100%; } .bell.ring { animation: ring .9s ease; } @keyframes ring { 0%, 100% { rotate: 0; } 15% { rotate: -14deg; } 30% { rotate: 12deg; } 45% { rotate: -8deg; } 60% { rotate: 5deg; } 75% { rotate: -2deg; } } .card { width: min(440px, 100%); background: var(--card); color: var(--card-ink); border-radius: 4px; padding: 28px 30px 24px; box-shadow: 0 24px 50px rgba(0,0,0,.5), 0 0 0 1px rgba(0,0,0,.05); background-image: linear-gradient(var(--card-line), var(--card-line)); background-size: calc(100% - 28px) 1px; background-position: 14px 14px; background-repeat: no-repeat; display: grid; gap: 14px; rotate: -0.6deg; } .card .kicker { margin: 0; font-size: 11px; letter-spacing: .28em; text-transform: uppercase; color: var(--card-muted); } .card h1 { margin: -4px 0 0; font: 400 34px/1.05 var(--serif); letter-spacing: -.005em; } .card .sub { margin: -4px 0 2px; color: var(--card-muted); font-size: 15px; } .primary { width: 100%; min-height: 52px; border: 0; border-radius: 3px; cursor: pointer; background: var(--oxblood); color: var(--oxblood-ink); font-family: inherit; font-size: 16px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 10px; box-shadow: 0 2px 0 #5a1a14, inset 0 1px 0 rgba(255,255,255,.15); } .primary .chip { font-size: 10px; letter-spacing: .08em; text-transform: uppercase; background: rgba(255, 244, 228, .18); padding: 3px 7px; border-radius: 2px; } .primary:focus-visible, .linkish:focus-visible { outline: 2px solid #b07a1e; outline-offset: 3px; } input:focus { outline: 2px solid #b07a1e; outline-offset: 1px; border-color: #b07a1e; } .linkish { border: 0; background: none; padding: 0; min-height: 40px; color: var(--oxblood); font: inherit; font-size: 14px; font-weight: 600; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; justify-self: start; } .row { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; } label { display: grid; gap: 6px; font-size: 12px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; color: var(--card-muted); } input { width: 100%; min-height: 46px; padding: 0 12px; border-radius: 3px; border: 1px solid #c9b48d; background: #fffaf0; color: var(--card-ink); font: inherit; font-size: 16px; letter-spacing: normal; text-transform: none; } input[aria-invalid="true"] { border-color: var(--err); } .msg { min-height: 20px; margin: 0; font-size: 14px; } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } .demo { margin: 0; font-size: 12px; color: var(--card-muted); border-top: 1px dashed var(--card-line); padding-top: 10px; } .demo .linkish { font-size: 12px; min-height: 32px; } [hidden] { display: none !important; } @media (max-width: 700px) { .rack { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 10px; } .cubby.deco.extra { display: none; } .bell { left: auto; right: 12px; top: 26px; width: 52px; } .card h1 { font-size: 30px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } .key.taken { scale: 1.15; } } </style> </head> <body> <main class="hall"> <div aria-hidden="true"><div class="pendant"></div><div class="lamp-shade"></div></div> <div class="sign" aria-hidden="true"><b>Hotel Northwind</b><span>Front desk</span></div> <section class="rack" aria-label="Sign-in keys" id="rack"> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div><div class="env"></div></div> <div class="cubby has-key" data-method="google"><span class="plate" aria-hidden="true">101</span></div> <div class="cubby deco" aria-hidden="true"><div class="hook"></div><div class="env b"></div></div> <div class="cubby has-key" data-method="apple"><span class="plate" aria-hidden="true">102</span></div> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div></div> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div><div class="env c"></div><div class="env b"></div></div> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div></div> <div class="cubby deco" aria-hidden="true"><div class="hook"></div><div class="env"></div></div> <div class="cubby has-key" data-method="github"><span class="plate" aria-hidden="true">103</span></div> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div><div class="env c"></div></div> <div class="cubby has-key" data-method="email"><span class="plate" aria-hidden="true">104</span></div> <div class="cubby deco extra" aria-hidden="true"><div class="hook"></div></div> </section> <section class="desk"> <svg class="bell" id="bell" viewBox="0 0 64 44" aria-hidden="true"> <defs><linearGradient id="bellG" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f7e2a8"/><stop offset=".5" stop-color="#c8994a"/><stop offset="1" stop-color="#7d5a22"/></linearGradient></defs> <rect x="30" y="2" width="4" height="7" rx="2" fill="#7d5a22"/> <path d="M8 36 A24 26 0 0 1 56 36 Z" fill="url(#bellG)"/> <path d="M16 22 A16 18 0 0 1 30 12" stroke="#fff6d6" stroke-width="2" fill="none" opacity=".6"/> <rect x="2" y="36" width="60" height="6" rx="3" fill="#3a2414"/> </svg> <div class="card" id="card"> <p class="kicker" id="kicker">Registration</p> <h1 id="title">Good evening</h1> <p class="sub" id="sub">Take your key from the rack to sign in.</p> <div id="returning" hidden> <button class="primary" id="primary" type="button"></button> </div> <form id="emailForm" class="grid" novalidate hidden style="display:grid;gap:12px"> <label>Email <input id="email" name="email" type="email" autocomplete="username" inputmode="email" required> </label> <label>Password <input id="password" name="password" type="password" autocomplete="current-password" required minlength="8"> </label> <button class="primary" id="emailSubmit" type="submit">Sign in with email</button> </form> <p class="msg" id="msg" role="status" aria-live="polite"></p> <div class="row" id="afterRow" hidden> <a class="primary" href="#" id="goRoom" style="text-decoration:none;flex:1;padding:0 18px">Go to your dashboard</a> </div> <p class="demo">Demo: <button class="linkish" type="button" id="simulate">Simulate a return visit</button> · <button class="linkish" type="button" id="forget">Forget this device</button></p> </div> </section> </main> <script> (() => { const KEY = "flow-key-rack"; const METHODS = { google: { name: "Google", initial: "G", hint: "a•••@gmail.com" }, apple: { name: "Apple", initial: "A", hint: "a•••@icloud.com" }, github: { name: "GitHub", initial: "GH", hint: "@a•••" }, email: { name: "Email", initial: "@", hint: "a•••@northwind.co" }, }; const store = { get() { try { return JSON.parse(localStorage.getItem(KEY) || "null"); } catch { return null; } }, set(v) { try { localStorage.setItem(KEY, JSON.stringify(v)); } catch {} }, clear() { try { localStorage.removeItem(KEY); } catch {} }, }; const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; const $ = (id) => document.getElementById(id); const title = $("title"), sub = $("sub"), kicker = $("kicker"), msg = $("msg"); const returning = $("returning"), primary = $("primary"); const emailForm = $("emailForm"), email = $("email"), password = $("password"), emailSubmit = $("emailSubmit"); const afterRow = $("afterRow"), bell = $("bell"); let signedIn = false; function keySvg(m, room) { return `<svg viewBox="0 0 80 120" aria-hidden="true"> <defs><linearGradient id="b-${m}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f7e2a8"/><stop offset=".45" stop-color="#d4a956"/><stop offset="1" stop-color="#8a6526"/></linearGradient></defs> <circle cx="40" cy="4" r="3.5" fill="#8a6526"/> <circle cx="40" cy="14" r="8" fill="none" stroke="url(#b-${m})" stroke-width="3"/> <rect x="37.5" y="20" width="5" height="26" rx="2" fill="url(#b-${m})"/> <path d="M42 36 h7 v4 h-4 v4 h5 v4 h-8z" fill="url(#b-${m})"/> <path d="M40 48 C58 52 64 70 62 84 C60 102 50 114 40 116 C30 114 20 102 18 84 C16 70 22 52 40 48 Z" fill="url(#b-${m})" stroke="#6e4f1c" stroke-width="1"/> <path d="M40 54 C53 58 57 72 55 84 C53 98 46 107 40 108 C34 107 27 98 25 84 C23 72 27 58 40 54 Z" fill="none" stroke="#7a5820" stroke-width="1" opacity=".7"/> <text x="40" y="83" text-anchor="middle" font-family="DM Serif Display, Georgia, serif" font-size="${METHODS[m].initial.length > 1 ? 16 : 22}" fill="#3a280c">${METHODS[m].initial}</text> <text x="40" y="99" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="7.5" font-weight="700" letter-spacing="1" fill="#4b3410">${METHODS[m].name.toUpperCase()}</text> </svg>`; } // Build the key buttons. document.querySelectorAll(".cubby.has-key").forEach((c) => { const m = c.dataset.method; const b = document.createElement("button"); b.type = "button"; b.className = "key"; b.dataset.method = m; b.innerHTML = keySvg(m) + `<span class="tag" aria-hidden="true">Last used</span><span class="sr">Continue with ${METHODS[m].name}</span>`; b.addEventListener("click", () => choose(m, b)); c.prepend(b); }); const keyFor = (m) => document.querySelector(`.key[data-method="${m}"]`); function greeting() { const h = new Date().getHours(); return h < 12 ? "Good morning" : h < 18 ? "Good afternoon" : "Good evening"; } function render(animate) { const last = store.get(); document.querySelectorAll(".cubby").forEach((c) => c.classList.remove("lit")); document.querySelectorAll(".key").forEach((k) => { k.classList.remove("last", "taken"); k.querySelector(".sr").textContent = `Continue with ${METHODS[k.dataset.method].name}`; }); signedIn = false; afterRow.hidden = true; emailForm.hidden = true; msg.className = "msg"; msg.textContent = ""; kicker.textContent = "Registration"; if (last && METHODS[last.method]) { const m = METHODS[last.method]; const k = keyFor(last.method); k.closest(".cubby").classList.add("lit"); k.querySelector(".sr").textContent = `Continue with ${m.name} (last used)`; if (animate && !reduced) { void k.offsetWidth; } k.classList.add("last"); title.textContent = "Welcome back"; sub.textContent = `Last time you used ${m.name} (${last.hint}).`; primary.innerHTML = `Continue with ${m.name} <span class="chip">Last used</span>`; primary.onclick = () => choose(last.method, k); returning.hidden = false; if (animate && !reduced) { bell.classList.remove("ring"); void bell.getBoundingClientRect(); bell.classList.add("ring"); } } else { title.textContent = greeting(); sub.textContent = "Take your key from the rack to sign in."; returning.hidden = true; } } function checkedIn(m) { store.set({ method: m, hint: METHODS[m].hint }); signedIn = true; kicker.textContent = "Checked in"; title.textContent = "Enjoy your stay"; sub.textContent = `Signed in with ${METHODS[m].name}. Next time, this key will be waiting.`; returning.hidden = true; emailForm.hidden = true; msg.className = "msg ok"; msg.textContent = `Signed in with ${METHODS[m].name}.`; afterRow.hidden = false; $("goRoom").focus({ preventScroll: true }); } function take(k) { document.querySelectorAll(".key.taken").forEach((x) => x !== k && x.classList.remove("taken")); k.classList.remove("last"); void k.offsetWidth; k.classList.add("taken"); } async function choose(m, k) { take(k); msg.className = "msg"; msg.textContent = ""; if (m === "email") { returning.hidden = true; afterRow.hidden = true; emailForm.hidden = false; title.textContent = "Sign in with email"; sub.textContent = "Your key to room 104."; email.focus(); return; } // Your real OAuth redirect goes here. Feedback is immediate: msg.textContent = `Opening ${METHODS[m].name}…`; await new Promise((r) => setTimeout(r, reduced ? 0 : 700)); checkedIn(m); } emailForm.addEventListener("submit", async (e) => { e.preventDefault(); if (emailSubmit.disabled) return; msg.className = "msg"; msg.textContent = ""; email.removeAttribute("aria-invalid"); password.removeAttribute("aria-invalid"); const fail = (input, text) => { input.focus(); input.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = text; }; if (!/^\S+@\S+\.\S+$/.test(email.value.trim())) return fail(email, "Enter your email address, like name@example.com."); if (password.value.length < 8) return fail(password, "Your password is at least 8 characters."); emailSubmit.disabled = true; emailSubmit.textContent = "Checking you in…"; await new Promise((r) => setTimeout(r, 700)); emailSubmit.disabled = false; emailSubmit.textContent = "Sign in with email"; if (password.value === "wrongpass") return fail(password, "That password doesn't match this email. Try again or reset it."); const local = email.value.trim().split("@"); METHODS.email.hint = `${local[0][0]}•••@${local[1]}`; checkedIn("email"); }); $("simulate").addEventListener("click", () => { if (!store.get()) store.set({ method: "github", hint: METHODS.github.hint }); render(true); primary.focus({ preventScroll: true }); }); $("forget").addEventListener("click", () => { store.clear(); render(false); msg.className = "msg ok"; msg.textContent = "This device no longer remembers you."; }); render(true); })(); </script> </body> </html>

Guardrails

  • Every key is a real button named "Continue with <provider>" (marked "last used" when it was); the decorative cubbies are aria-hidden.
  • Only the method and a masked hint are stored, with localStorage wrapped in try/catch; "Forget this device" clears it.
  • 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.