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>