Flow

Screens / Login / Sign in

Elevator

An art-deco elevator lobby where the sign-in form is a brass plaque and its submit is the call button: the needle sweeps up while you're signed in, then the doors open on a warm car that welcomes you by name.

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

The idea

Signing in is going up: you call the elevator, it rises to your floor, and the doors open onto your space.

Palette

#0b231e
#d4ad5f
#f3d998
#f1e6c8
#2a1608
#ff5a4a

Type and motion

Marcellus (fallback Didot/Georgia) for the plaque headline, dial numerals and car welcome; system sans with tracked uppercase for labels and the call button.

The call bulb lights instantly, the floor needle sweeps across the semicircular dial for about a second, and on success the doors part with a slow ease-in-out to reveal the lit car.

Make it yours

  • A vintage train platform with a split-flap board that flips to "Now boarding: Ada" before the carriage door opens.
  • A speakeasy door with a sliding peephole: the slot opens, eyes check you, and the door swings into a warm bar.
  • An observatory dome whose shutter slides open onto the stars when you sign in.

Why it's engaging

Pressing sign-in feels like calling an elevator, with a lit call button, a sweeping floor needle, a soft chime and brass doors sliding open on your welcome.

Why it also works better

The button lights the instant you press it and the needle shows progress, so there's no dead wait; errors drop the needle and light a red lamp, and the open car hands you straight to the next action.

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>Elevator login | Flow pattern</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Marcellus&display=swap"> <!-- Flow login pattern: Elevator (https://flow.donta.io/screens/login/elevator) An art-deco elevator lobby. The sign-in form is a brass-framed plaque on the lobby wall and its submit is the call button: it lights the moment you press it, the floor needle sweeps up while you're signed in, then a soft chime and the doors slide open on a warm car that welcomes you by name. On a wrong password the needle drops back, a red lamp lights and the message says what to do. The form works from the first frame; the elevator only answers it. Demo: any email and an 8+ character password signs in; "wrongpass" shows the error. --> <style> :root { --wall: #0b231e; --wall-2: #12332b; --lacquer: #070f0d; --brass: #d4ad5f; --brass-hi: #f3d998; --brass-lo: #8a6a2c; --cream: #f1e6c8; --muted: #b9ae92; --line: #3d5a50; --err: #ff8f70; --ok: #b9e6c4; --lamp: #ffcf6b; --deco: "Marcellus", "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 { position: relative; min-height: 100vh; color: var(--cream); overflow-x: hidden; background: radial-gradient(ellipse 60% 45% at 32% 20%, rgba(243, 217, 152, 0.10), transparent 70%), repeating-linear-gradient(90deg, transparent 0 46px, rgba(0,0,0,.18) 46px 48px, rgba(255,255,255,.025) 48px 50px), linear-gradient(180deg, var(--wall-2), var(--wall) 70%, #06120f); } /* marble floor */ body::after { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: max(15vh, 110px); z-index: -1; background: linear-gradient(180deg, rgba(212,173,95,.5), rgba(212,173,95,0) 3px), radial-gradient(ellipse 22% 70% at 30% 0%, rgba(212,173,95,.16), transparent 70%), linear-gradient(115deg, transparent 40%, rgba(255,255,255,.05) 45%, transparent 52%), linear-gradient(180deg, #101614, #040605); } .lobby { min-height: 100vh; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: center; justify-items: center; gap: clamp(24px, 5vw, 80px); padding: clamp(24px, 5vw, 64px) clamp(16px, 5vw, 72px) 18vh; max-width: 1200px; margin: 0 auto; } /* ── Elevator ─────────────────────────────── */ .shaft { width: min(360px, 82vw); display: grid; justify-items: center; position: relative; } .sconce { position: absolute; top: 36%; width: 14px; height: 54px; border-radius: 7px 7px 2px 2px; background: linear-gradient(var(--brass-hi), var(--brass-lo)); box-shadow: 0 -30px 60px 20px rgba(255, 207, 107, 0.16); } .sconce.l { left: -38px; } .sconce.r { right: -38px; } .dial-wrap { width: 72%; position: relative; z-index: 2; margin-bottom: -2px; } .dial { width: 100%; display: block; overflow: visible; } .needle { transform-box: view-box; transform-origin: 130px 128px; rotate: -80deg; transition: rotate 1.1s cubic-bezier(.45,.05,.25,1); } .lamps { position: absolute; left: -16px; right: -16px; bottom: 2px; display: flex; justify-content: space-between; pointer-events: none; } .lamps i { width: 11px; height: 11px; border-radius: 50%; background: #3b2a1c; box-shadow: inset 0 1px 2px rgba(0,0,0,.6); transition: background-color .2s, box-shadow .2s; } .up.on { background: var(--lamp); box-shadow: 0 0 10px 2px rgba(255,207,107,.8); } .stop.on { background: #ff5a4a; box-shadow: 0 0 10px 2px rgba(255,90,74,.8); } .frame { width: 100%; position: relative; padding: 14px; border-radius: 4px; background: linear-gradient(135deg, var(--brass-hi), var(--brass) 30%, var(--brass-lo) 70%, var(--brass)); box-shadow: 0 30px 60px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.25); } .frame::before { display: none; /* the dial is the crown */ content: ""; position: absolute; left: 50%; top: -28px; translate: -50% 0; width: 46%; height: 30px; background: linear-gradient(135deg, var(--brass-hi), var(--brass-lo)); clip-path: polygon(0 100%, 8% 100%, 8% 66%, 20% 66%, 20% 33%, 34% 33%, 34% 0, 66% 0, 66% 33%, 80% 33%, 80% 66%, 92% 66%, 92% 100%, 100% 100%); } .doors { position: relative; aspect-ratio: 3 / 4; overflow: hidden; background: #1c140c; } .car { position: absolute; inset: 0; display: grid; place-content: center; justify-items: center; gap: 14px; text-align: center; padding: 22px; background: radial-gradient(ellipse 80% 40% at 50% 0%, rgba(255, 240, 200, .95), rgba(255, 220, 150, 0) 70%), repeating-linear-gradient(90deg, rgba(90, 45, 15, .0) 0 38px, rgba(90, 45, 15, .22) 38px 40px), linear-gradient(180deg, #f2cf8f, #c98a45 70%, #8d5426); color: #2a1608; } .car::before { content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 16%; background: linear-gradient(180deg, #5c3416, #3a1f0c); box-shadow: inset 0 2px 0 rgba(255,220,160,.35); } .car > * { position: relative; z-index: 1; } .car::after { content: ""; position: absolute; left: 10%; right: 10%; top: 70%; height: 6px; border-radius: 3px; background: linear-gradient(var(--brass-hi), var(--brass-lo)); box-shadow: 0 3px 6px rgba(60,30,10,.35); } .car .floor { font: 14px/1 var(--deco); letter-spacing: .3em; text-transform: uppercase; opacity: .7; } .car h2 { margin: 0; font: 400 clamp(24px, 3.2vw, 32px)/1.1 var(--deco); } .car a { position: relative; z-index: 1; display: inline-grid; place-items: center; min-height: 44px; padding: 0 20px; border-radius: 999px; background: #2a1608; color: var(--brass-hi); text-decoration: none; font-weight: 600; font-size: 15px; letter-spacing: .02em; } .car a:focus-visible { outline: 3px solid #2a1608; outline-offset: 3px; } .door { position: absolute; top: 0; bottom: 0; width: 50%; z-index: 1; background: linear-gradient(90deg, rgba(0,0,0,.25), transparent 8%, transparent 92%, rgba(0,0,0,.35)), linear-gradient(100deg, transparent 30%, rgba(255,245,215,.28) 38%, transparent 46%), linear-gradient(160deg, #e9c983, #b98c41 40%, #8a6428 75%, #b38a45); transition: translate 1.2s cubic-bezier(.65,0,.25,1); } .door svg { position: absolute; inset: 8% 10%; width: 80%; height: 84%; } .door.l { left: 0; border-right: 1px solid #5b4218; } .door.r { right: 0; border-left: 1px solid #f5dfa6; } .is-open .door.l { translate: -101% 0; } .is-open .door.r { translate: 101% 0; } .car[aria-hidden="true"] a { visibility: hidden; } /* ── Plaque form ──────────────────────────── */ .plaque { width: min(400px, 100%); position: relative; padding: 34px 32px 28px; background: linear-gradient(180deg, #0d2923, #08201b); border: 1px solid var(--brass); outline: 1px solid rgba(212,173,95,.45); outline-offset: 6px; box-shadow: 0 30px 70px rgba(0,0,0,.5); display: grid; gap: 16px; } .plaque::before, .plaque::after { /* deco corner fans */ content: ""; position: absolute; width: 46px; height: 46px; pointer-events: none; background: repeating-conic-gradient(from 0deg at 0 0, var(--brass) 0 3deg, transparent 3deg 12deg); -webkit-mask: radial-gradient(circle at 0 0, #000 70%, transparent 71%); mask: radial-gradient(circle at 0 0, #000 70%, transparent 71%); opacity: .7; } .plaque::before { left: 0; top: 0; } .plaque::after { right: 0; bottom: 0; rotate: 180deg; } .eyebrow { margin: 0; font: 13px/1 var(--deco); letter-spacing: .32em; text-transform: uppercase; color: var(--brass); text-align: center; } h1 { margin: 0; text-align: center; font: 400 clamp(32px, 4vw, 40px)/1.05 var(--deco); letter-spacing: .01em; color: var(--cream); } .rule { display: flex; align-items: center; gap: 10px; width: 70%; margin: -2px auto 2px; } .rule::before, .rule::after { content: ""; flex: 1; height: 1px; background: linear-gradient(90deg, transparent, var(--brass)); } .rule::after { background: linear-gradient(90deg, var(--brass), transparent); } .rule i { width: 8px; height: 8px; rotate: 45deg; border: 1px solid var(--brass); background: rgba(212,173,95,.35); } label { display: grid; gap: 7px; font-size: 13px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); } input { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 2px; border: 1px solid var(--line); background: #061512; color: var(--cream); font: inherit; font-size: 16px; letter-spacing: normal; text-transform: none; } input:focus { outline: 2px solid var(--brass); outline-offset: 2px; border-color: var(--brass); } input[aria-invalid="true"] { border-color: var(--err); } .row { display: flex; justify-content: flex-end; font-size: 14px; margin-top: -4px; } .row a { color: var(--brass-hi); } .call { justify-self: stretch; min-height: 54px; border-radius: 999px; border: 0; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 14px; background: linear-gradient(180deg, var(--brass-hi), var(--brass) 45%, var(--brass-lo)); color: #1f1606; font-weight: 600; font-size: 16px; line-height: 1; font-family: inherit; letter-spacing: .06em; text-transform: uppercase; box-shadow: 0 2px 0 #5c4519, 0 8px 18px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.5); transition: translate .08s, box-shadow .08s; } .call:active { translate: 0 2px; box-shadow: 0 0 0 #5c4519, 0 4px 10px rgba(0,0,0,.4), inset 0 1px 0 rgba(255,255,255,.5); } .call .bulb { width: 26px; height: 26px; border-radius: 50%; display: grid; place-items: center; background: radial-gradient(circle at 40% 35%, #5a4422, #2b1e0b); color: #8a6a2c; font-size: 11px; box-shadow: inset 0 1px 3px rgba(0,0,0,.6), 0 0 0 2px #6b5121; transition: background .15s, color .15s, box-shadow .15s; } .call.lit .bulb { background: radial-gradient(circle at 40% 35%, #fff4cf, var(--lamp) 55%, #e09a2a); color: #6b3d00; box-shadow: 0 0 0 2px #6b5121, 0 0 18px 4px rgba(255, 207, 107, .85); } .call:disabled { cursor: progress; } .call:focus-visible { outline: 2px solid var(--cream); outline-offset: 3px; } .msg { min-height: 20px; margin: 0; font-size: 14px; text-align: center; } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } .msg a { color: var(--brass-hi); } .hint { margin: 0; font-size: 12px; color: #8f8670; text-align: center; } @media (max-width: 760px) { .lobby { grid-template-columns: 1fr; padding-top: 52px; gap: 40px; } .shaft { width: min(260px, 70vw); } .sconce { display: none; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } </style> </head> <body> <main class="lobby" id="lobby"> <section class="shaft" aria-label="Elevator"> <span class="sconce l" aria-hidden="true"></span><span class="sconce r" aria-hidden="true"></span> <div class="dial-wrap" aria-hidden="true"> <svg class="dial" viewBox="0 0 260 140"> <defs> <linearGradient id="dialFace" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1b130a"/><stop offset="1" stop-color="#0d0905"/></linearGradient> <linearGradient id="dialRim" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#f3d998"/><stop offset=".5" stop-color="#b98c41"/><stop offset="1" stop-color="#f0d38c"/></linearGradient> </defs> <path d="M6 132 A124 124 0 0 1 254 132 Z" fill="url(#dialRim)"/> <path d="M16 132 A114 114 0 0 1 244 132 Z" fill="url(#dialFace)"/> <g id="ticks" stroke="#d4ad5f" stroke-width="2" stroke-linecap="round"></g> <g id="nums" fill="#f1e6c8" font-family="Marcellus, Didot, Georgia, serif" font-size="13" text-anchor="middle"></g> <g class="needle" id="needle"> <path d="M130 128 L127 50 L130 34 L133 50 Z" fill="#f3d998"/> <circle cx="130" cy="128" r="9" fill="url(#dialRim)"/> </g> </svg> <div class="lamps"><i class="up" id="lampUp"></i><i class="stop" id="lampStop"></i></div> </div> <div class="frame"> <div class="doors" id="doors"> <div class="car" id="car" aria-hidden="true"> <span class="floor" id="floorText">Floor 9</span> <h2 id="welcome">Welcome back</h2> <a href="#" id="go">Go to dashboard</a> </div> <div class="door l" aria-hidden="true"><svg viewBox="0 0 100 300" preserveAspectRatio="none"><use href="#inlay"/></svg></div> <div class="door r" aria-hidden="true"><svg viewBox="0 0 100 300" preserveAspectRatio="none" style="transform:scaleX(-1)"><use href="#inlay"/></svg></div> </div> </div> <svg width="0" height="0" style="position:absolute" aria-hidden="true"> <symbol id="inlay" viewBox="0 0 100 300"> <g fill="none" stroke="#5e441a" stroke-width="1.2" opacity=".75"> <rect x="4" y="4" width="92" height="292"/> <rect x="12" y="12" width="76" height="276"/> <path d="M100 150 L12 60 M100 150 L12 100 M100 150 L12 150 M100 150 L12 200 M100 150 L12 240"/> <path d="M100 150 L60 12 M100 150 L60 288"/> <path d="M12 30 L50 30 L50 12 M12 270 L50 270 L50 288"/> </g> <path d="M100 120 A30 30 0 0 0 100 180" fill="#f3d998" opacity=".35"/> </symbol> </svg> </section> <form class="plaque" id="form" novalidate> <p class="eyebrow">The Grand Lobby</p> <h1>Welcome back</h1> <div class="rule" aria-hidden="true"><i></i></div> <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> <div class="row"><a href="#">Forgot password?</a></div> <button class="call" id="submit" type="submit"><span class="bulb" aria-hidden="true">▲</span><span id="callLabel">Sign in</span></button> <p class="msg" id="msg" role="status" aria-live="polite"></p> <p class="hint">Demo: any email, 8+ character password. "wrongpass" shows the error.</p> </form> </main> <script> (() => { const FLOORS = ["L", "2", "3", "4", "5", "6", "7", "8", "9"]; const ticks = document.getElementById("ticks"); const nums = document.getElementById("nums"); const NS = "http://www.w3.org/2000/svg"; FLOORS.forEach((f, i) => { const a = (-80 + (160 / (FLOORS.length - 1)) * i) * Math.PI / 180; const sx = 130 + Math.sin(a) * 98, sy = 128 - Math.cos(a) * 98; const ex = 130 + Math.sin(a) * 108, ey = 128 - Math.cos(a) * 108; const t = document.createElementNS(NS, "line"); t.setAttribute("x1", sx); t.setAttribute("y1", sy); t.setAttribute("x2", ex); t.setAttribute("y2", ey); ticks.append(t); const n = document.createElementNS(NS, "text"); n.setAttribute("x", 130 + Math.sin(a) * 82); n.setAttribute("y", 132 - Math.cos(a) * 82); n.textContent = f; nums.append(n); }); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; const needle = document.getElementById("needle"); const lampUp = document.getElementById("lampUp"); const lampStop = document.getElementById("lampStop"); const doors = document.getElementById("doors"); const car = document.getElementById("car"); const form = document.getElementById("form"); const email = document.getElementById("email"); const password = document.getElementById("password"); const btn = document.getElementById("submit"); const callLabel = document.getElementById("callLabel"); const msg = document.getElementById("msg"); const setNeedle = (deg) => { needle.style.rotate = deg + "deg"; }; function chime() { try { const ac = new (window.AudioContext || window.webkitAudioContext)(); [[659.25, 0], [523.25, 0.32]].forEach(([f, t]) => { const o = ac.createOscillator(), g = ac.createGain(); o.type = "sine"; o.frequency.value = f; g.gain.setValueAtTime(0.0001, ac.currentTime + t); g.gain.exponentialRampToValueAtTime(0.07, ac.currentTime + t + 0.02); g.gain.exponentialRampToValueAtTime(0.0001, ac.currentTime + t + 1.1); o.connect(g).connect(ac.destination); o.start(ac.currentTime + t); o.stop(ac.currentTime + t + 1.2); }); } catch {} } function reset() { doors.classList.remove("is-open"); car.setAttribute("aria-hidden", "true"); lampStop.classList.remove("on"); lampUp.classList.remove("on"); btn.classList.remove("lit"); } function fail(input, text) { input.focus(); input.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = text; } form.addEventListener("submit", async (e) => { e.preventDefault(); if (btn.disabled) return; reset(); msg.className = "msg"; msg.textContent = ""; email.removeAttribute("aria-invalid"); password.removeAttribute("aria-invalid"); 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."); // Answer immediately: the call button lights and the car starts up. btn.classList.add("lit"); lampUp.classList.add("on"); btn.disabled = true; btn.setAttribute("aria-busy", "true"); callLabel.textContent = "Calling…"; setNeedle(80); await new Promise((r) => setTimeout(r, 1200)); // your real request goes here btn.disabled = false; btn.removeAttribute("aria-busy"); callLabel.textContent = "Sign in"; lampUp.classList.remove("on"); if (password.value === "wrongpass") { setNeedle(-80); btn.classList.remove("lit"); lampStop.classList.add("on"); return fail(password, "That password doesn't match this email. Try again or reset it."); } const raw = email.value.split("@")[0].split(/[._-]+/)[0] || ""; const name = raw ? raw[0].toUpperCase() + raw.slice(1) : ""; document.getElementById("welcome").textContent = name ? `Welcome back, ${name}` : "Welcome back"; msg.className = "msg ok"; msg.textContent = `Signed in. Welcome back${name ? ", " + name : ""}.`; if (!reduced) chime(); doors.classList.add("is-open"); car.removeAttribute("aria-hidden"); setTimeout(() => document.getElementById("go").focus({ preventScroll: true }), reduced ? 0 : 900); }); })(); </script> </body> </html>

Guardrails

  • The call button is the form's submit button, never a gesture to discover, and reads "Calling…" while pending.
  • The chime is quiet, plays only after the submit press and never under reduced motion; the car's "Go to dashboard" link stays hidden from assistive tech until the doors open.
  • 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.