Flow

Screens / Login / Sign in

Lamp pull

A dark room with a floor lamp. Pull the cord and warm light spills across the wallpaper, the floor and the sign-in card.

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

The idea

Coming home at night: you switch on the light, and the room (and your account) appears.

Palette

#0c0a08
#16130f
#f4c35a
#ffd98a
#f3ede2
#a89f90

Type and motion

Fraunces italic 600 for the greeting (fallback Palatino/Georgia), system sans for labels and controls.

The cord stretches on a spring and snaps back; light fades up over 0.5s, revealing wallpaper only where it falls, with slow drifting dust motes.

Make it yours

  • A neon OPEN sign that flickers on above the form for a late-night food app.
  • A theatre curtain cord that parts velvet curtains onto the form for a ticketing app.
  • A breaker switch that lights up a workshop bench for a maker or tools brand.

Why it's engaging

A physical, playful first action with spring physics on the cord, dust drifting in the beam and a warm reveal of the room.

Why it also works better

Sets a calm, focused mood for a product with a home or night feel. The form is dim but usable from the start, and focusing it turns the light on, so nothing is gated.

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>Lamp pull login | Flow pattern</title> <!-- Flow login pattern: Lamp pull (https://flow.donta.io/screens/login/lamp-pull) Pull the cord (drag, click, or Enter) to switch the lamp on. The form is a real form from the start: focusing a field or typing also turns the light on, so the interaction never stands between someone and their account. Demo: any email and a password of 8+ characters signs in; the password "wrongpass" shows the error state. --> <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=Fraunces:ital,opsz,wght@1,9..144,400;1,9..144,600&display=swap" rel="stylesheet"> <style> :root { --ink: #f3ede2; --muted: #a89f90; --line: #3b342b; --card: #16130f; --accent: #f4c35a; --accent-ink: #1d1606; --err: #ff8f6b; --ok: #9be3b8; --serif: "Fraunces", "Iowan Old Style", "Palatino Linotype", Palatino, 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; background: #0c0a08; color: var(--ink); display: grid; place-items: center; padding: 24px; overflow: hidden; position: relative; } /* Room: wallpaper and floor that only show where the lamp lights them. */ .room { position: fixed; inset: 0; pointer-events: none; --lx: 33%; --ly: 34%; } .wall { position: absolute; inset: 0 0 24% 0; background: repeating-linear-gradient(90deg, rgba(255, 214, 150, 0.05) 0 1px, transparent 1px 22px), repeating-linear-gradient(90deg, rgba(255, 214, 150, 0.035) 0 9px, transparent 9px 44px), #120f0c; -webkit-mask: radial-gradient(60vmax 52vmax at var(--lx) var(--ly), #000 0%, rgba(0,0,0,.45) 45%, transparent 75%); mask: radial-gradient(60vmax 52vmax at var(--lx) var(--ly), #000 0%, rgba(0,0,0,.45) 45%, transparent 75%); opacity: calc(var(--on) * 1); transition: opacity .6s ease; } .floor { position: absolute; left: 0; right: 0; bottom: 0; height: 24%; background: linear-gradient(#0e0c0a, #090807); border-top: 1px solid rgba(255, 220, 160, calc(0.05 + var(--on) * 0.08)); } .pool { position: absolute; left: var(--lx); bottom: 9%; width: 46vmax; height: 11vmax; translate: -50% 0; background: radial-gradient(closest-side, rgba(255, 196, 104, 0.30), rgba(255, 180, 90, 0.08) 60%, transparent); opacity: var(--on); transition: opacity .6s ease; } body:has(.is-on) { --on: 1; } body { --on: 0; } .scene { position: relative; z-index: 1; width: min(1080px, 100%); display: grid; grid-template-columns: 1.15fr 1fr; align-items: end; gap: 32px; } /* Lamp */ .lamp-wrap { --h: min(620px, 74vh); position: relative; height: var(--h); display: grid; place-items: end center; } .lamp { height: var(--h); width: calc(var(--h) * 220 / 360); overflow: visible; } .glow { position: absolute; left: 50%; top: 14%; width: calc(var(--h) * 1.3); height: calc(var(--h) * 1.1); translate: -50% 0; background: radial-gradient(closest-side, rgba(255, 205, 110, 0.26), rgba(255, 190, 90, 0.07) 55%, transparent 75%); opacity: var(--on); transition: opacity 0.5s ease; pointer-events: none; } .cone { opacity: calc(var(--on) * 0.8); transition: opacity 0.45s ease; } .bulb { fill: color-mix(in srgb, #ffe2a0 calc(var(--on) * 100%), #4a4338); transition: fill 0.25s ease; } .shade-in { fill: color-mix(in srgb, #ffcf7a calc(var(--on) * 60%), #221e19); transition: fill .35s ease; } .motes { position: absolute; inset: 0; pointer-events: none; opacity: calc(var(--on) * 0.75); transition: opacity .8s ease; } .motes i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #ffe6b0; box-shadow: 0 0 6px rgba(255, 220, 150, .9); animation: drift 9s ease-in-out infinite alternate; } @keyframes drift { 0% { translate: 0 0; opacity: .2; } 50% { opacity: 1; } 100% { translate: 18px -60px; opacity: .3; } } .cord { position: absolute; left: calc(50% + var(--h) * 0.1); top: calc(var(--h) * 0.255); width: 44px; height: calc(var(--h) * 0.3); display: grid; justify-items: center; align-content: start; background: none; border: 0; padding: 0; cursor: grab; touch-action: none; } .cord:active { cursor: grabbing; } .cord .string { width: 2px; height: calc(var(--h) * 0.16); background: linear-gradient(#5d554a, #9c917f); transition: height 0.35s cubic-bezier(.3,1.6,.5,1); } .cord .knob { width: 13px; height: 20px; border-radius: 7px; background: radial-gradient(circle at 35% 30%, #ffe3a1, var(--accent) 60%, #b98a2a); box-shadow: 0 0 calc(var(--on) * 16px) rgba(255, 200, 100, 0.85); } .cord:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 8px; } .cord-hint { position: absolute; left: calc(50% + var(--h) * 0.1 + 34px); top: calc(var(--h) * 0.4); font: italic 17px/1.2 var(--serif); color: #cbbfa9; white-space: nowrap; display: flex; align-items: center; gap: 6px; opacity: calc(1 - var(--on)); transition: opacity .3s; } .cord-hint svg { width: 28px; height: 18px; } /* Form */ .card { position: relative; width: min(380px, 100%); justify-self: center; margin-bottom: 6vh; background: linear-gradient(160deg, rgba(34, 29, 23, 0.94), rgba(20, 17, 13, 0.96)); border: 1px solid var(--line); border-radius: 22px; padding: 32px 30px 26px; display: grid; gap: 16px; /* Dim but readable when off; never disabled. */ filter: brightness(calc(0.62 + var(--on) * 0.38)); box-shadow: -40px 10px 90px -50px rgba(255, 190, 90, calc(var(--on) * 0.55)), 0 30px 60px rgba(0, 0, 0, 0.45); transition: filter 0.5s ease, box-shadow 0.6s ease; } .eyebrow { margin: 0; font-size: 13px; color: var(--muted); letter-spacing: 0.02em; } h1 { margin: -6px 0 0; font: italic 600 38px/1.05 var(--serif); letter-spacing: -0.01em; color: #fff4e0; } .sub { margin: -6px 0 6px; color: var(--muted); font-size: 15px; } label { display: grid; gap: 7px; font-size: 14px; font-weight: 600; color: #e6dccb; } input { min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: #0f0d0a; color: var(--ink); font: inherit; font-size: 16px; } input:focus { outline: 3px solid color-mix(in srgb, var(--accent) 40%, transparent); border-color: var(--accent); } input[aria-invalid="true"] { border-color: var(--err); } .row { display: flex; justify-content: space-between; align-items: center; font-size: 14px; } a { color: var(--accent); } .btn { min-height: 50px; border: 0; border-radius: 12px; color: var(--accent-ink); background: linear-gradient(#f8cf6e, #eab448); box-shadow: inset 0 1px 0 rgba(255,255,255,.4), 0 8px 24px rgba(240, 180, 70, calc(var(--on) * .25)); font-family: inherit; font-weight: 700; font-size: 16px; cursor: pointer; } .btn[aria-busy="true"] { opacity: 0.7; cursor: progress; } .msg { min-height: 20px; margin: 0; font-size: 14px; } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } .hint { font-size: 12px; color: var(--muted); margin: 0; } @media (max-width: 760px) { body { place-items: start center; overflow: auto; } .scene { grid-template-columns: 1fr; gap: 0; } .lamp-wrap { --h: 300px; } .card { margin-bottom: 0; margin-top: -10px; } .room { --lx: 50%; --ly: 18%; } .floor, .pool { display: none; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } .motes i { animation: none; } } </style> </head> <body> <div class="room" aria-hidden="true"><div class="wall"></div><div class="floor"></div><div class="pool"></div></div> <main class="scene" id="scene"> <div class="lamp-wrap"> <div class="glow" aria-hidden="true"></div> <svg class="lamp" viewBox="0 0 220 360" aria-hidden="true"> <defs> <linearGradient id="cone" x1="0" y1="0" x2="0" y2="1"> <stop offset="0" stop-color="#ffd98a" stop-opacity="0.6" /> <stop offset="0.7" stop-color="#ffcf7a" stop-opacity="0.12" /> <stop offset="1" stop-color="#ffcf7a" stop-opacity="0" /> </linearGradient> <linearGradient id="shade" x1="0" y1="0" x2="1" y2="0"> <stop offset="0" stop-color="#2b2620" /><stop offset="0.55" stop-color="#3a332a" /><stop offset="1" stop-color="#211d18" /> </linearGradient> <linearGradient id="pole" x1="0" y1="0" x2="1" y2="0"> <stop offset="0" stop-color="#1f1b17" /><stop offset="0.5" stop-color="#4a4136" /><stop offset="1" stop-color="#1f1b17" /> </linearGradient> </defs> <polygon class="cone" points="66,96 154,96 232,362 -12,362" fill="url(#cone)" /> <ellipse class="shade-in" cx="110" cy="95" rx="49" ry="7" /> <path d="M60 96 L82 50 H138 L160 96 Z" fill="url(#shade)" stroke="#4d453a" stroke-width="1.5" /> <path d="M82 50 H138" stroke="#6a5f50" stroke-width="2" /> <ellipse class="bulb" cx="110" cy="97" rx="20" ry="5" /> <rect x="107.5" y="99" width="5" height="246" fill="url(#pole)" /> <ellipse cx="110" cy="350" rx="40" ry="7" fill="#26211b" /> <ellipse cx="110" cy="347" rx="40" ry="6" fill="#332c24" /> </svg> <div class="motes" aria-hidden="true"> <i style="left:44%;top:42%;animation-delay:-1s"></i><i style="left:52%;top:55%;animation-delay:-3s"></i> <i style="left:38%;top:66%;animation-delay:-5s"></i><i style="left:58%;top:72%;animation-delay:-2s"></i> <i style="left:47%;top:80%;animation-delay:-6s"></i><i style="left:62%;top:60%;animation-delay:-4s"></i> <i style="left:35%;top:52%;animation-delay:-7s"></i> </div> <button class="cord" id="cord" type="button" aria-pressed="false" aria-label="Turn the light on"> <span class="string" id="string"></span> <span class="knob"></span> </button> <span class="cord-hint" aria-hidden="true"> <svg viewBox="0 0 28 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><path d="M27 4 C18 2, 9 6, 3 12" /><path d="M3 6 L3 12 L9 12" /></svg> pull the cord </span> </div> <form class="card" id="form" novalidate> <p class="eyebrow">Good evening</p> <h1>Welcome back</h1> <p class="sub">Sign in to pick up where you left off.</p> <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" style="justify-content:flex-end"><a href="#">Forgot password?</a></div> <button class="btn" id="submit" type="submit">Sign in</button> <p class="msg" id="msg" role="status" aria-live="polite"></p> <p class="hint">Demo: any email, 8+ character password. Use "wrongpass" to see the error.</p> </form> </main> <script> (() => { const scene = document.getElementById("scene"); const cord = document.getElementById("cord"); const string = document.getElementById("string"); const form = document.getElementById("form"); const email = document.getElementById("email"); const password = document.getElementById("password"); const submit = document.getElementById("submit"); const msg = document.getElementById("msg"); function setOn(on) { scene.classList.toggle("is-on", on); cord.setAttribute("aria-pressed", String(on)); cord.setAttribute("aria-label", on ? "Turn the light off" : "Turn the light on"); } const toggle = () => setOn(!scene.classList.contains("is-on")); // Pull: drag the knob down past 30px and release. Click / Enter / Space also work. let startY = null, pulled = 0, dragged = false; cord.addEventListener("pointerdown", (e) => { startY = e.clientY; dragged = false; cord.setPointerCapture(e.pointerId); string.style.transition = "none"; }); cord.addEventListener("pointermove", (e) => { if (startY === null) return; pulled = Math.max(0, Math.min(60, e.clientY - startY)); if (pulled > 4) dragged = true; string.style.height = 70 + pulled + "px"; }); cord.addEventListener("pointerup", () => { if (startY === null) return; string.style.transition = ""; string.style.height = ""; if (!dragged || pulled > 30) toggle(); startY = null; pulled = 0; }); cord.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } }); // Never gate the form: any interaction with it turns the light on. form.addEventListener("focusin", () => setOn(true)); form.addEventListener("submit", async (e) => { e.preventDefault(); msg.className = "msg"; msg.textContent = ""; email.removeAttribute("aria-invalid"); password.removeAttribute("aria-invalid"); const v = email.value.trim(); if (!/^\S+@\S+\.\S+$/.test(v)) { email.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = "Enter your email address, like name@example.com."; email.focus(); return; } if (password.value.length < 8) { password.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = "Your password is at least 8 characters."; password.focus(); return; } submit.setAttribute("aria-busy", "true"); submit.disabled = true; submit.textContent = "Signing in…"; await new Promise((r) => setTimeout(r, 700)); submit.removeAttribute("aria-busy"); submit.disabled = false; submit.textContent = "Sign in"; if (password.value === "wrongpass") { password.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = "That password doesn't match this email. Try again or reset it."; password.select(); return; } msg.className = "msg ok"; msg.textContent = "Signed in. Welcome back."; }); })(); </script> </body> </html>

Guardrails

  • The cord is a real button (click, Enter, Space) as well as draggable.
  • Focusing any field turns the light on, so the gesture is never required.
  • 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.