Flow

Screens / Login / Sign in

Constellation

A deep night sky where every character you type places a star, and the stars join into a constellation that glows and takes your name when you sign in.

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

The idea

Your sign-in is a small act of creation: the letters you type become stars, and finishing makes them a constellation.

Palette

#04050d
#0c0e1d
#b9adff
#ffd27a
#eef0ff
#ff8a7a

Type and motion

Sora (fallback system sans) at 300 for the sky caption and constellation name, 600 for the card headline.

Stars bloom in as you type and lines draw themselves between them, error stars flicker red briefly, and success sends one shooting star while the lines warm to gold.

Make it yours

  • A koi pond where each keystroke drops a lily pad, and the koi gather into a circle when you sign in.
  • A city skyline at dusk where each character lights a window, and success switches on the whole block.
  • A seed-and-vine path where letters plant seeds that bloom into a flower bearing your initial.

Why it's engaging

Typing quietly draws something beautiful, and success ends with a shooting star and a personal constellation like "Ada's Heron".

Why it also works better

A faint ghost constellation shows how far along you are while you type, the newest stars flicker red on an error, and the solid card keeps every label and message fully legible.

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>Constellation login | Flow pattern</title> <link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@300;400;600&display=swap"> <!-- Flow login pattern: Constellation (https://flow.donta.io/screens/login/constellation) A deep night sky. Every character you type places a new star along a path, and as the form fills the stars join into a constellation. A wrong password makes the newest stars flicker red; signing in sends a shooting star across the sky and the constellation glows and takes a name. The sign-in card is a solid panel, so text contrast never depends on the sky. Demo: any email and an 8+ character password signs in; "wrongpass" shows the error. --> <style> :root { --space: #04050d; --card: #0c0e1d; --ink: #eef0ff; --muted: #a3a8c9; --line: #262a4a; --accent: #b9adff; --accent-ink: #0b0b1a; --gold: #ffd27a; --err: #ff8a7a; --ok: #9ff0c9; font-family: "Sora", 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: var(--space); color: var(--ink); overflow-x: hidden; } canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; } .layout { position: relative; min-height: 100vh; display: grid; grid-template-columns: 1.15fr minmax(320px, 400px); align-items: center; gap: 48px; padding: clamp(24px, 5vw, 72px); max-width: 1240px; margin: 0 auto; } .sky-copy { align-self: end; padding-bottom: 4vh; max-width: 34ch; } .sky-copy p { margin: 0; color: #c9cdf0; font-size: clamp(18px, 2vw, 24px); font-weight: 300; letter-spacing: -.01em; line-height: 1.3; } .sky-copy strong { display: block; margin-top: 6px; color: var(--gold); font-weight: 300; font-size: 30px; letter-spacing: -.01em; min-height: 40px; opacity: 0; translate: 0 6px; transition: opacity .8s ease .6s, translate .8s ease .6s; } .named .sky-copy strong { opacity: 1; translate: 0 0; } .card { position: relative; background: var(--card); border: 1px solid var(--line); border-radius: 22px; padding: 32px 30px 26px; display: grid; gap: 16px; box-shadow: 0 40px 100px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.04); } .card::before { /* faint orbit line on the card edge */ content: ""; position: absolute; inset: -1px; border-radius: inherit; padding: 1px; pointer-events: none; background: linear-gradient(140deg, rgba(185,173,255,.55), transparent 35%, transparent 70%, rgba(255,210,122,.35)); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask-composite: exclude; } .mark { width: 34px; height: 34px; } h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.02em; } .sub { margin: -8px 0 4px; color: var(--muted); font-size: 14px; font-weight: 300; } label { display: grid; gap: 7px; font-size: 13px; font-weight: 600; color: var(--muted); letter-spacing: .02em; } input { width: 100%; min-height: 48px; padding: 0 14px; border-radius: 12px; border: 1px solid var(--line); background: #070815; color: var(--ink); font: inherit; font-size: 16px; font-weight: 400; } input:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: transparent; } input[aria-invalid="true"] { border-color: var(--err); } .row { display: flex; justify-content: flex-end; font-size: 13px; margin-top: -6px; } a { color: var(--accent); } .btn { min-height: 50px; border: 0; border-radius: 12px; cursor: pointer; background: linear-gradient(180deg, #cfc6ff, var(--accent)); color: var(--accent-ink); font-family: inherit; font-weight: 600; font-size: 16px; letter-spacing: .01em; box-shadow: 0 10px 30px rgba(185,173,255,.25); } .btn:disabled { opacity: .75; cursor: progress; } .btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; } .msg { min-height: 20px; margin: 0; font-size: 14px; } .msg.err { color: var(--err); } .msg.ok { color: var(--ok); } .hint { margin: 0; font-size: 12px; color: #757aa0; } @media (max-width: 820px) { .layout { grid-template-columns: 1fr; padding-top: 38vh; gap: 20px; } .sky-copy { position: absolute; top: 20px; left: 20px; right: 20px; padding: 0; } .sky-copy p { font-size: 16px; } .sky-copy strong { font-size: 22px; } } @media (prefers-reduced-motion: reduce) { * { transition: none !important; } } </style> </head> <body> <canvas id="sky" aria-hidden="true"></canvas> <main class="layout" id="layout"> <div class="sky-copy" aria-hidden="true"> <p>Every letter you type is a star.</p> <strong id="skyName"></strong> </div> <form class="card" id="form" novalidate> <svg class="mark" viewBox="0 0 34 34" aria-hidden="true"><path d="M17 3l2.6 8.4L28 14l-8.4 2.6L17 25l-2.6-8.4L6 14l8.4-2.6z" fill="#ffd27a"/><circle cx="27" cy="27" r="2" fill="#b9adff"/><circle cx="7" cy="28" r="1.4" fill="#b9adff"/></svg> <h1>Sign in</h1> <p class="sub">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"><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. "wrongpass" shows the error.</p> </form> </main> <script> (() => { const canvas = document.getElementById("sky"); const ctx = canvas.getContext("2d"); const layout = document.getElementById("layout"); const email = document.getElementById("email"); const password = document.getElementById("password"); const form = document.getElementById("form"); const btn = document.getElementById("submit"); const msg = document.getElementById("msg"); const skyName = document.getElementById("skyName"); const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches; const dpr = Math.min(devicePixelRatio || 1, 2); let W = 0, H = 0, field = [], path = []; // Seeded random so the sky and the constellation shape are stable. let seed = 7; const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647; // The constellation lives in the open sky: left of the card on desktop, above it on phones. function region() { const wide = innerWidth > 820; return wide ? { x: W * 0.06, y: H * 0.12, w: W * 0.46, h: H * 0.62 } : { x: W * 0.08, y: H * 0.07, w: W * 0.84, h: H * 0.26 }; } function build() { W = canvas.width = innerWidth * dpr; H = canvas.height = innerHeight * dpr; seed = 7; field = Array.from({ length: Math.round((W * H) / (2600 * dpr * dpr)) }, () => ({ x: rnd() * W, y: rnd() * H, r: (rnd() ** 3 * 1.4 + 0.3) * dpr, tw: rnd() * 6.28, sp: 0.5 + rnd() * 1.5, })); // A graceful sweeping curve of 28 anchors (like a wing), with small seeded wobble. const R = region(); path = []; for (let i = 0; i < 28; i++) { const u = i / 27; const x = 0.04 + 0.92 * u + (rnd() - 0.5) * 0.04; const y = 0.55 - 0.34 * Math.sin(u * Math.PI * 1.5 + 0.35) + 0.07 * Math.sin(u * Math.PI * 6.5) + (rnd() - 0.5) * 0.05; path.push({ x: R.x + x * R.w, y: R.y + y * R.h, big: i % 4 === 0 }); } stars.forEach((s, i) => Object.assign(s, place(i))); } const stars = []; // { x, y, born, r, red } function place(i) { const a = path[i % path.length]; const j = 4 * dpr; seed = 1000 + i * 97; return { x: a.x + (rnd() - 0.5) * j * 2, y: a.y + (rnd() - 0.5) * j * 2, r: (a.big ? 2.8 : 1.5 + rnd() * 0.9) * dpr }; } function sync() { const n = Math.min(email.value.length + password.value.length, path.length); while (stars.length < n) stars.push({ ...place(stars.length), born: performance.now(), red: 0 }); while (stars.length > n) stars.pop(); } let glow = 0, glowTarget = 0, shooting = null, named = false; function completeness() { const e = /^\S+@\S+\.\S+$/.test(email.value.trim()) ? 1 : Math.min(email.value.length / 14, 0.85); return (e + Math.min(password.value.length / 8, 1)) / 2; } function draw(now) { const t = now / 1000; ctx.fillStyle = "#04050d"; ctx.fillRect(0, 0, W, H); // nebula const neb = [[0.22, 0.35, "rgba(76, 54, 160, 0.22)"], [0.42, 0.7, "rgba(30, 70, 150, 0.18)"], [0.8, 0.15, "rgba(120, 50, 130, 0.14)"]]; for (const [nx, ny, c] of neb) { const g = ctx.createRadialGradient(W * nx, H * ny, 0, W * nx, H * ny, Math.max(W, H) * 0.45); g.addColorStop(0, c); g.addColorStop(1, "rgba(0,0,0,0)"); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); } // background field for (const s of field) { const a = reduced ? 0.6 : 0.35 + 0.35 * Math.sin(t * s.sp + s.tw); ctx.fillStyle = `rgba(220, 225, 255, ${a})`; ctx.beginPath(); ctx.arc(s.x, s.y, s.r, 0, 6.283); ctx.fill(); } // ghost of the constellation still to come if (glow < 0.05) { const from = Math.max(stars.length - 1, 0); ctx.setLineDash([2 * dpr, 6 * dpr]); ctx.lineWidth = 1 * dpr; ctx.strokeStyle = "rgba(185, 173, 255, 0.28)"; ctx.beginPath(); for (let i = from; i < path.length; i++) { const a = path[i]; i === from ? ctx.moveTo(a.x, a.y) : ctx.lineTo(a.x, a.y); } ctx.stroke(); ctx.setLineDash([]); for (let i = stars.length; i < path.length; i++) { const a = path[i]; ctx.fillStyle = `rgba(210, 200, 255, ${a.big ? 0.6 : 0.38})`; ctx.beginPath(); ctx.arc(a.x, a.y, (a.big ? 2 : 1.3) * dpr, 0, 6.283); ctx.fill(); } } // constellation lines const c = completeness(); glow += (glowTarget - glow) * 0.05; if (stars.length > 1) { ctx.lineWidth = (1 + glow * 1.2) * dpr; for (let i = 1; i < stars.length; i++) { const a = stars[i - 1], b = stars[i]; const age = reduced ? 1 : Math.min((now - b.born) / 500, 1); const alpha = (0.12 + 0.4 * c + 0.4 * glow) * age; ctx.strokeStyle = glow > 0.05 ? `rgba(255, 214, 140, ${alpha})` : `rgba(185, 173, 255, ${alpha})`; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(a.x + (b.x - a.x) * age, a.y + (b.y - a.y) * age); ctx.stroke(); // occasional branch back to an earlier star, so it reads as a constellation, not a line if (i % 7 === 4 && i >= 4) { const k = stars[i - 4]; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.lineTo(b.x + (k.x - b.x) * age, b.y + (k.y - b.y) * age); ctx.stroke(); } } } // stars for (const s of stars) { const age = reduced ? 1 : Math.min((now - s.born) / 350, 1); let r = s.r * (age < 1 ? 1 + (1 - age) * 1.6 : 1) * (1 + glow * 0.4); const red = s.red > now; const flick = red && !reduced ? 0.5 + 0.5 * Math.sin(now / 45) : 1; const col = red ? `255, 120, 110` : glow > 0.05 ? `255, 222, 160` : `235, 238, 255`; const halo = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, r * 6); halo.addColorStop(0, `rgba(${col}, ${0.55 * flick})`); halo.addColorStop(1, `rgba(${col}, 0)`); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(s.x, s.y, r * 6, 0, 6.283); ctx.fill(); ctx.fillStyle = `rgba(${col}, ${flick})`; ctx.beginPath(); ctx.arc(s.x, s.y, r, 0, 6.283); ctx.fill(); // four-point glint on bright stars if (s.r > 2.4 * dpr || glow > 0.6) { ctx.strokeStyle = `rgba(${col}, ${0.35 * flick})`; ctx.lineWidth = 1 * dpr; ctx.beginPath(); ctx.moveTo(s.x - r * 4, s.y); ctx.lineTo(s.x + r * 4, s.y); ctx.moveTo(s.x, s.y - r * 4); ctx.lineTo(s.x, s.y + r * 4); ctx.stroke(); } } // shooting star if (shooting) { const p = (now - shooting.start) / 1100; if (p >= 1) shooting = null; else { const x = shooting.x0 + (shooting.x1 - shooting.x0) * p, y = shooting.y0 + (shooting.y1 - shooting.y0) * p; const tx = x - (shooting.x1 - shooting.x0) * 0.18, ty = y - (shooting.y1 - shooting.y0) * 0.18; const g = ctx.createLinearGradient(tx, ty, x, y); g.addColorStop(0, "rgba(255,255,255,0)"); g.addColorStop(1, `rgba(255, 240, 210, ${1 - p * 0.6})`); ctx.strokeStyle = g; ctx.lineWidth = 2.5 * dpr; ctx.lineCap = "round"; ctx.beginPath(); ctx.moveTo(tx, ty); ctx.lineTo(x, y); ctx.stroke(); } } } let raf = 0; function loop(now) { raf = requestAnimationFrame(loop); if (!document.hidden) draw(now); } const redraw = () => { if (reduced) draw(performance.now()); }; build(); addEventListener("resize", () => { build(); redraw(); }); if (reduced) draw(performance.now()); else raf = requestAnimationFrame(loop); for (const el of [email, password]) el.addEventListener("input", () => { sync(); if (glowTarget) { glowTarget = 0; named = false; layout.classList.remove("named"); } redraw(); }); const NAMES = ["Heron", "Lantern", "Kite", "Lyre", "Compass", "Fox", "Harbor"]; function fail(input, text) { input.focus(); input.setAttribute("aria-invalid", "true"); msg.className = "msg err"; msg.textContent = text; const until = performance.now() + 1400; stars.slice(-4).forEach((s) => (s.red = until)); redraw(); } form.addEventListener("submit", async (e) => { e.preventDefault(); if (btn.disabled) return; 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."); btn.disabled = true; btn.textContent = "Signing in…"; await new Promise((r) => setTimeout(r, 800)); // your real request goes here btn.disabled = false; btn.textContent = "Sign in"; if (password.value === "wrongpass") return fail(password, "That password doesn't match this email. Try again or reset it."); const raw = email.value.split("@")[0].split(/[._-]+/)[0] || ""; const who = raw ? raw[0].toUpperCase() + raw.slice(1) : "Your"; let h = 0; for (const ch of email.value) h = (h * 31 + ch.charCodeAt(0)) >>> 0; const nm = `${who}${raw ? "'s" : ""} ${NAMES[h % NAMES.length]}`; skyName.textContent = nm; glowTarget = 1; named = true; layout.classList.add("named"); if (reduced) glow = 1; else { const R = region(); shooting = { start: performance.now(), x0: R.x + R.w * 1.1, y0: H * 0.02, x1: R.x - R.w * 0.1, y1: R.y + R.h * 0.9 }; } msg.className = "msg ok"; msg.textContent = `Signed in. Your constellation tonight: ${nm}.`; redraw(); }); })(); </script> </body> </html>

Guardrails

  • The sky is an aria-hidden canvas behind a solid card, so contrast never depends on it; the constellation name is also announced in the status text.
  • The loop pauses while the tab is hidden, and reduced motion draws a single still sky with no twinkle or shooting star.
  • 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.