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>