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>Paper plane sign-in | Flow pattern</title>
<!--
Flow login pattern: Paper plane (https://flow.donta.io/screens/login/paper-plane)
Passwordless sign-in as a piece of air mail on a cutting mat. The letter card
holds one email field. Send it and the card folds into a paper plane that flies
off along a dotted flight path, then a postcard slides in telling you exactly
where the link went, with buttons that open common mail apps, a way to fix a
typo, and a visible resend countdown. A bad address makes the letter crumple
and shake once, with the fix in plain words. The fold and flight are
decorative: under reduced motion the postcard simply appears.
Demo: any valid email sends the link; "ada@example" shows the error.
-->
<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=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
<style>
:root {
--mat: #26463a;
--mat-deep: #1d382e;
--paper: #f7f4ec;
--paper-edge: #e4dccb;
--ink: #1c2742;
--ink-soft: #4b5571;
--red: #d2442f;
--blue: #2c55a3;
--stamp: #e9b949;
--err: #b3261e;
--ok: #1f6b4a;
--serif: "Instrument Serif", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
--noise: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .32 0 0 0 0 .27 0 0 0 0 .18 0 0 0 .09 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
* { box-sizing: border-box; }
html, body { margin: 0; min-height: 100%; }
body {
min-height: 100vh;
font-family: var(--sans);
color: var(--ink);
background:
linear-gradient(rgba(255,255,255,.10) 1px, transparent 1px) 0 0 / 120px 120px,
linear-gradient(90deg, rgba(255,255,255,.10) 1px, transparent 1px) 0 0 / 120px 120px,
linear-gradient(rgba(255,255,255,.045) 1px, transparent 1px) 0 0 / 24px 24px,
linear-gradient(90deg, rgba(255,255,255,.045) 1px, transparent 1px) 0 0 / 24px 24px,
radial-gradient(ellipse at 50% 40%, var(--mat), var(--mat-deep) 80%);
display: grid;
place-items: center;
padding: 56px 20px;
overflow-x: hidden;
}
/* Ruler edges on the cutting mat */
.ruler { position: fixed; pointer-events: none; color: rgba(255,255,255,.45); }
.ruler.top { top: 0; left: 0; right: 0; height: 22px; background:
repeating-linear-gradient(90deg, rgba(255,255,255,.35) 0 1px, transparent 1px 24px) bottom / 100% 10px no-repeat,
repeating-linear-gradient(90deg, rgba(255,255,255,.5) 0 1px, transparent 1px 120px) bottom / 100% 18px no-repeat; }
.ruler.left { top: 0; bottom: 0; left: 0; width: 22px; background:
repeating-linear-gradient(rgba(255,255,255,.35) 0 1px, transparent 1px 24px) right / 10px 100% no-repeat,
repeating-linear-gradient(rgba(255,255,255,.5) 0 1px, transparent 1px 120px) right / 18px 100% no-repeat; }
.brand {
position: fixed; top: 34px; left: 44px; display: flex; align-items: center; gap: 10px;
color: #eef3ef; font-family: var(--serif); font-size: 22px; letter-spacing: .01em;
}
.brand svg { width: 26px; height: 26px; }
/* Desk props */
.props { position: fixed; inset: 0; pointer-events: none; }
.props svg { position: absolute; overflow: visible; }
.pencil { left: 6%; bottom: 9%; width: 300px; rotate: -28deg; filter: drop-shadow(6px 10px 6px rgba(0,0,0,.35)); }
.clip { right: 9%; top: 15%; width: 70px; rotate: 18deg; filter: drop-shadow(2px 4px 3px rgba(0,0,0,.35)); }
.loose-stamp { right: 7%; bottom: 12%; width: 92px; rotate: 11deg; filter: drop-shadow(3px 6px 5px rgba(0,0,0,.35)); }
.loose-stamp.two { right: 15%; bottom: 7%; width: 80px; rotate: -8deg; }
.route { left: 0; top: 0; width: 100%; height: 100%; }
.route path { fill: none; stroke: rgba(255,255,255,.22); stroke-width: 2; stroke-dasharray: 1 11; stroke-linecap: round; }
@media (max-width: 900px) { .pencil, .clip, .loose-stamp { display: none; } .brand { top: 30px; left: 36px; font-size: 20px; } }
/* The letter */
.stage { position: relative; width: min(470px, 100%); display: grid; }
.stage > * { grid-area: 1 / 1; }
.letter {
position: relative;
padding: 11px;
border-radius: 6px;
background: repeating-linear-gradient(135deg, var(--red) 0 16px, var(--paper) 16px 24px, var(--blue) 24px 40px, var(--paper) 40px 48px);
box-shadow: 0 1px 0 rgba(255,255,255,.2) inset, 0 28px 60px -18px rgba(0,0,0,.55), 0 6px 14px rgba(0,0,0,.25);
rotate: -1.2deg; align-self: center;
transition: opacity .18s ease, scale .25s ease, rotate .3s ease;
}
.letter.folding { opacity: 0; scale: .96; }
.letter.gone { visibility: hidden; }
.letter.crumple { animation: crumple .55s cubic-bezier(.36,.07,.19,.97); }
@keyframes crumple {
0% { transform: none; }
18% { transform: scale(.97, .99) skewX(-1.5deg) rotate(-1deg); filter: brightness(.97); }
36% { transform: scale(.985) skewX(1.8deg) rotate(1.2deg) translateX(6px); }
54% { transform: scale(.975) skewX(-1.2deg) translateX(-6px); }
72% { transform: scale(.99) skewX(.6deg) translateX(3px); }
100% { transform: none; }
}
.paper {
position: relative;
padding: 34px 34px 26px;
background: var(--noise), linear-gradient(170deg, #fbf9f3, var(--paper) 60%, #f1ece0);
border-radius: 2px;
}
.stamp {
position: absolute; top: 22px; right: 24px; width: 66px; height: 80px;
rotate: 3deg; filter: drop-shadow(0 1.5px 1.5px rgba(40,30,10,.28));
}
.stamp svg { width: 100%; height: 100%; display: block; }
.postmark { position: absolute; top: 34px; right: 70px; width: 128px; height: 74px; opacity: .78; rotate: -9deg; mix-blend-mode: multiply; }
.by-air {
margin: 0 0 18px; font: 600 11px/1 var(--mono); letter-spacing: .22em; color: var(--blue);
display: inline-block; padding: 6px 9px; border: 1.5px solid var(--blue); border-radius: 3px; rotate: -2deg;
margin-bottom: 46px;
}
h1 {
margin: 0 0 10px; font-family: var(--serif); font-weight: 400; font-size: clamp(38px, 6vw, 48px);
line-height: .98; letter-spacing: -.01em; max-width: 9.5em;
}
h1 em { font-style: italic; color: var(--red); }
.sub { margin: 0 0 26px; color: var(--ink-soft); font-size: 16px; line-height: 1.5; max-width: 30ch; }
label { display: grid; gap: 4px; font: 600 12px/1.2 var(--mono); letter-spacing: .12em; text-transform: uppercase; color: var(--ink-soft); }
.field { position: relative; }
input[type="email"] {
width: 100%; min-height: 52px; padding: 10px 2px 6px;
border: 0; border-bottom: 2px solid var(--ink); border-radius: 0;
background: repeating-linear-gradient(transparent 0 25px, rgba(44,85,163,.10) 25px 26px);
font: 400 22px/1.2 var(--serif); color: var(--ink); letter-spacing: .01em;
text-transform: none;
}
input[type="email"]::placeholder { color: #a39c8d; font-style: italic; }
input[type="email"]:focus { outline: none; border-bottom-color: var(--blue); box-shadow: 0 6px 0 -3px rgba(44,85,163,.28); }
input[aria-invalid="true"] { border-bottom-color: var(--err); }
.msg { min-height: 22px; margin: 10px 0 0; font-size: 14px; line-height: 1.4; }
.msg.err { color: var(--err); }
.send {
margin-top: 14px; width: 100%; min-height: 54px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--ink); color: #f7f4ec; font: 600 16px/1 var(--sans); letter-spacing: .01em;
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
box-shadow: 0 2px 0 #0d1428, 0 10px 20px -10px rgba(28,39,66,.7);
transition: transform .12s ease, background-color .2s;
}
.send:hover { background: #26345a; }
.send:active { transform: translateY(1px); }
.send:focus-visible { outline: 3px solid var(--stamp); outline-offset: 3px; }
.send svg { width: 20px; height: 20px; }
.send[aria-busy="true"] { cursor: progress; background: #33416a; }
.send[aria-busy="true"] svg { animation: wobble .6s ease-in-out infinite alternate; }
@keyframes wobble { from { transform: translate(-2px, 1px) rotate(-6deg); } to { transform: translate(3px, -2px) rotate(6deg); } }
.fine { margin: 18px 0 0; display: flex; justify-content: space-between; gap: 12px; font: 12px/1.4 var(--mono); color: #7a7464; }
.hint { margin: 14px 0 0; font-size: 12px; color: #7a7464; }
/* Postcard */
.postcard {
position: relative;
background: var(--noise), linear-gradient(160deg, #fbf8f1, #f2ecdf);
border-radius: 6px;
box-shadow: 0 30px 60px -20px rgba(0,0,0,.6), 0 6px 14px rgba(0,0,0,.25);
padding: 26px 26px 22px;
rotate: 1.4deg;
opacity: 0; translate: 0 60px; scale: .96;
pointer-events: none; visibility: hidden; align-self: center;
transition: opacity .45s ease, translate .6s cubic-bezier(.2,.9,.25,1.15), scale .6s cubic-bezier(.2,.9,.25,1.15), visibility 0s .6s;
}
.postcard.show { opacity: 1; translate: 0 0; scale: 1; pointer-events: auto; visibility: visible; transition-delay: 0s; }
.postcard:focus { outline: none; }
.pc-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 14px; }
.pc-label { font: 600 11px/1 var(--mono); letter-spacing: .2em; color: var(--red); margin: 6px 0 0; }
.pc-stamp { flex: none; width: 56px; height: 66px; rotate: -4deg; filter: drop-shadow(0 1.5px 1.5px rgba(40,30,10,.28)); }
.pc-stamp svg { width: 100%; height: 100%; display: block; }
.pc-grid { display: grid; grid-template-columns: 1.15fr 1fr; gap: 22px; }
.pc-grid > div:first-child { border-right: 1.5px solid #d8cfbd; padding-right: 22px; }
.postcard h2 { margin: 0 0 8px; font: 400 36px/1 var(--serif); letter-spacing: -.01em; }
.postcard h2 em { color: var(--blue); }
.note { margin: 0; font: italic 400 19px/1.35 var(--serif); color: var(--ink-soft); }
.addr-lines { display: grid; gap: 10px; align-content: start; font: 13px/1.3 var(--mono); color: var(--ink-soft); }
.addr-lines span { display: block; border-bottom: 1px solid #d8cfbd; padding-bottom: 6px; }
.addr-lines b { color: var(--ink); font-weight: 600; overflow-wrap: anywhere; font-size: 14px; }
.apps { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 20px; }
.app {
min-height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 8px;
border-radius: 10px; border: 1.5px solid var(--ink); color: var(--ink); text-decoration: none;
font: 600 15px/1 var(--sans); background: rgba(255,255,255,.55);
}
.app:hover { background: #fff; }
.app:focus-visible, .link:focus-visible { outline: 3px solid var(--blue); outline-offset: 2px; }
.pc-foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
.link { min-height: 44px; border: 0; background: none; padding: 0 2px; color: var(--blue); font: 600 14px/1 var(--sans); cursor: pointer; text-decoration: underline; text-underline-offset: 3px; }
.link:disabled { color: #8c8574; text-decoration: none; cursor: default; font-weight: 400; font-family: var(--mono); font-size: 13px; }
.pc-msg { margin: 0; min-height: 20px; font-size: 13px; color: var(--ok); }
/* Flight overlay */
.flight { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; overflow: visible; z-index: 5; }
.flight .trail { fill: none; stroke: rgba(255,255,255,.85); stroke-width: 3; stroke-linecap: round; stroke-dasharray: .1 11; transition: opacity .8s ease; }
.flight .trail.fade { opacity: 0; }
@media (max-width: 560px) {
body { padding: 84px 14px 32px; place-items: start center; }
.paper { padding: 28px 22px 22px; }
.stamp { width: 60px; height: 72px; top: 18px; right: 16px; }
.postmark { right: 48px; top: 30px; width: 104px; }
.pc-grid { grid-template-columns: 1fr; gap: 14px; }
.pc-grid > div:first-child { border-right: 0; padding-right: 0; border-bottom: 1.5px solid #d8cfbd; padding-bottom: 14px; }
.apps { grid-template-columns: 1fr; }
.postcard h2 { font-size: 32px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { transition: none !important; animation: none !important; }
}
</style>
</head>
<body>
<div class="ruler top" aria-hidden="true"></div>
<div class="ruler left" aria-hidden="true"></div>
<div class="brand" aria-hidden="true">
<svg viewBox="0 0 32 32"><path d="M3 15 29 4 22 28 15 19z" fill="#f7f4ec"/><path d="M15 19 29 4 11 17z" fill="#d9d2c2"/><path d="M15 19 13 26 18 21" fill="#bdb5a3"/></svg>
Northwind Post
</div>
<div class="props" aria-hidden="true">
<svg class="route" id="route" preserveAspectRatio="none"><path id="routePath" d=""/></svg>
<svg class="pencil" viewBox="0 0 300 30">
<polygon points="0,15 26,4 26,26" fill="#f1d3a8"/>
<polygon points="0,15 9,11.3 9,18.7" fill="#2b2b2b"/>
<rect x="26" y="4" width="226" height="22" fill="#f2c14e"/>
<rect x="26" y="4" width="226" height="7" fill="#f7d77e"/>
<rect x="26" y="19" width="226" height="7" fill="#d9a635"/>
<rect x="252" y="3" width="16" height="24" fill="#b7b9bd"/>
<rect x="256" y="3" width="2" height="24" fill="#8e9095"/><rect x="262" y="3" width="2" height="24" fill="#8e9095"/>
<rect x="268" y="3" width="28" height="24" rx="5" fill="#e98a96"/>
<text x="60" y="19" font-family="ui-monospace,monospace" font-size="9" fill="#6b4f12" letter-spacing="2">NORTHWIND HB</text>
</svg>
<svg class="clip" viewBox="0 0 40 110"><path d="M10 100 V18 a10 10 0 0 1 20 0 V86 a6 6 0 0 1 -12 0 V30" fill="none" stroke="#c9ced6" stroke-width="3.2" stroke-linecap="round"/></svg>
<svg class="loose-stamp" viewBox="0 0 92 110">
<defs><mask id="perf"><rect width="92" height="110" fill="#fff"/><g fill="#000">
<circle cx="0" cy="0" r="0"/></g></mask></defs>
<rect x="2" y="2" width="88" height="106" fill="#f7f4ec" stroke="#f7f4ec" stroke-width="4" stroke-dasharray="2 4"/>
<rect x="10" y="10" width="72" height="90" fill="#2c55a3"/>
<circle cx="46" cy="44" r="16" fill="#e9b949"/>
<path d="M10 76 Q28 64 46 74 T82 70 V100 H10Z" fill="#1d3f80"/>
<text x="16" y="94" font-family="Georgia,serif" font-size="13" fill="#f7f4ec">25</text>
</svg>
<svg class="loose-stamp two" viewBox="0 0 92 110">
<rect x="2" y="2" width="88" height="106" fill="#f7f4ec" stroke="#f7f4ec" stroke-width="4" stroke-dasharray="2 4"/>
<rect x="10" y="10" width="72" height="90" fill="#d2442f"/>
<path d="M22 70 46 28 70 70Z" fill="#f7f4ec"/><path d="M38 70 46 52 54 70Z" fill="#d2442f"/>
<text x="56" y="94" font-family="Georgia,serif" font-size="13" fill="#f7f4ec">40</text>
</svg>
</div>
<main class="stage">
<form class="letter" id="letter" novalidate aria-labelledby="title">
<div class="paper">
<div class="stamp" aria-hidden="true">
<svg viewBox="0 0 70 84"><defs><mask id="perfA"><rect width="70" height="84" fill="#fff"/><g fill="#000"><circle cx="0.0" cy="0" r="2.6"/><circle cx="0.0" cy="84" r="2.6"/><circle cx="7.0" cy="0" r="2.6"/><circle cx="7.0" cy="84" r="2.6"/><circle cx="14.0" cy="0" r="2.6"/><circle cx="14.0" cy="84" r="2.6"/><circle cx="21.0" cy="0" r="2.6"/><circle cx="21.0" cy="84" r="2.6"/><circle cx="28.0" cy="0" r="2.6"/><circle cx="28.0" cy="84" r="2.6"/><circle cx="35.0" cy="0" r="2.6"/><circle cx="35.0" cy="84" r="2.6"/><circle cx="42.0" cy="0" r="2.6"/><circle cx="42.0" cy="84" r="2.6"/><circle cx="49.0" cy="0" r="2.6"/><circle cx="49.0" cy="84" r="2.6"/><circle cx="56.0" cy="0" r="2.6"/><circle cx="56.0" cy="84" r="2.6"/><circle cx="63.0" cy="0" r="2.6"/><circle cx="63.0" cy="84" r="2.6"/><circle cx="70.0" cy="0" r="2.6"/><circle cx="70.0" cy="84" r="2.6"/><circle cx="0" cy="0.0" r="2.6"/><circle cx="70" cy="0.0" r="2.6"/><circle cx="0" cy="7.0" r="2.6"/><circle cx="70" cy="7.0" r="2.6"/><circle cx="0" cy="14.0" r="2.6"/><circle cx="70" cy="14.0" r="2.6"/><circle cx="0" cy="21.0" r="2.6"/><circle cx="70" cy="21.0" r="2.6"/><circle cx="0" cy="28.0" r="2.6"/><circle cx="70" cy="28.0" r="2.6"/><circle cx="0" cy="35.0" r="2.6"/><circle cx="70" cy="35.0" r="2.6"/><circle cx="0" cy="42.0" r="2.6"/><circle cx="70" cy="42.0" r="2.6"/><circle cx="0" cy="49.0" r="2.6"/><circle cx="70" cy="49.0" r="2.6"/><circle cx="0" cy="56.0" r="2.6"/><circle cx="70" cy="56.0" r="2.6"/><circle cx="0" cy="63.0" r="2.6"/><circle cx="70" cy="63.0" r="2.6"/><circle cx="0" cy="70.0" r="2.6"/><circle cx="70" cy="70.0" r="2.6"/><circle cx="0" cy="77.0" r="2.6"/><circle cx="70" cy="77.0" r="2.6"/><circle cx="0" cy="84.0" r="2.6"/><circle cx="70" cy="84.0" r="2.6"/></g></mask></defs>
<g mask="url(#perfA)"><rect width="70" height="84" fill="#fbf9f3"/>
<rect x="5" y="5" width="60" height="74" fill="#2c55a3"/>
<circle cx="47" cy="25" r="9" fill="#e9b949"/>
<path d="M5 60 Q20 50 35 58 T65 54 V79 H5Z" fill="#1d3f80"/>
<path d="M12 38 38 27 33 43 29 37z" fill="#f7f4ec"/><path d="M29 37 38 27 25 36z" fill="#cfc7b6"/>
<text x="9" y="74" font-family="Georgia,serif" font-size="10" fill="#f7f4ec">25¢</text></g>
</svg>
</div>
<svg class="postmark" viewBox="0 0 130 74" aria-hidden="true">
<circle cx="38" cy="37" r="30" fill="none" stroke="#1c2742" stroke-width="2"/>
<circle cx="38" cy="37" r="23" fill="none" stroke="#1c2742" stroke-width="1"/>
<text x="38" y="33" text-anchor="middle" font-family="ui-monospace,monospace" font-size="7.5" fill="#1c2742" letter-spacing="1">NORTHWIND</text>
<text x="38" y="44" text-anchor="middle" font-family="ui-monospace,monospace" font-size="7" fill="#1c2742">AIR MAIL</text>
<path d="M70 20 q8 -5 15 0 t15 0 t15 0 t15 0 M70 30 q8 -5 15 0 t15 0 t15 0 t15 0 M70 40 q8 -5 15 0 t15 0 t15 0 t15 0 M70 50 q8 -5 15 0 t15 0 t15 0 t15 0" fill="none" stroke="#1c2742" stroke-width="1.6"/>
</svg>
<p class="by-air" aria-hidden="true">PAR AVION</p>
<h1 id="title">Sign in by <em>air mail</em></h1>
<p class="sub">No password to remember. We'll fly a one-tap sign-in link to your inbox.</p>
<label for="email">Email address</label>
<div class="field">
<input id="email" name="email" type="email" autocomplete="email" inputmode="email" required placeholder="you@example.com" aria-describedby="msg">
</div>
<p class="msg" id="msg" role="status" aria-live="polite"></p>
<button class="send" id="send" type="submit">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M2 11 22 3 17 21 11 14z" fill="currentColor"/><path d="M11 14 22 3 8 12z" fill="#b9c3dc"/></svg>
<span id="sendLabel">Email me a sign-in link</span>
</button>
<p class="fine"><span>Link works for 15 minutes</span><span>No. 0427</span></p>
<p class="hint">Demo: any valid email sends the link. Try "ada@example" to see the error.</p>
</div>
</form>
<section class="postcard" id="postcard" tabindex="-1" aria-labelledby="pcTitle" aria-live="polite">
<div class="pc-top">
<p class="pc-label" aria-hidden="true">POSTCARD · DELIVERED</p>
<div class="pc-stamp" aria-hidden="true">
<svg viewBox="0 0 56 66"><defs><mask id="perfB"><rect width="56" height="66" fill="#fff"/><g fill="#000"><circle cx="0.0" cy="0" r="2.3"/><circle cx="0.0" cy="66" r="2.3"/><circle cx="6.22" cy="0" r="2.3"/><circle cx="6.22" cy="66" r="2.3"/><circle cx="12.44" cy="0" r="2.3"/><circle cx="12.44" cy="66" r="2.3"/><circle cx="18.67" cy="0" r="2.3"/><circle cx="18.67" cy="66" r="2.3"/><circle cx="24.89" cy="0" r="2.3"/><circle cx="24.89" cy="66" r="2.3"/><circle cx="31.11" cy="0" r="2.3"/><circle cx="31.11" cy="66" r="2.3"/><circle cx="37.33" cy="0" r="2.3"/><circle cx="37.33" cy="66" r="2.3"/><circle cx="43.56" cy="0" r="2.3"/><circle cx="43.56" cy="66" r="2.3"/><circle cx="49.78" cy="0" r="2.3"/><circle cx="49.78" cy="66" r="2.3"/><circle cx="56.0" cy="0" r="2.3"/><circle cx="56.0" cy="66" r="2.3"/><circle cx="0" cy="0.0" r="2.3"/><circle cx="56" cy="0.0" r="2.3"/><circle cx="0" cy="6.6" r="2.3"/><circle cx="56" cy="6.6" r="2.3"/><circle cx="0" cy="13.2" r="2.3"/><circle cx="56" cy="13.2" r="2.3"/><circle cx="0" cy="19.8" r="2.3"/><circle cx="56" cy="19.8" r="2.3"/><circle cx="0" cy="26.4" r="2.3"/><circle cx="56" cy="26.4" r="2.3"/><circle cx="0" cy="33.0" r="2.3"/><circle cx="56" cy="33.0" r="2.3"/><circle cx="0" cy="39.6" r="2.3"/><circle cx="56" cy="39.6" r="2.3"/><circle cx="0" cy="46.2" r="2.3"/><circle cx="56" cy="46.2" r="2.3"/><circle cx="0" cy="52.8" r="2.3"/><circle cx="56" cy="52.8" r="2.3"/><circle cx="0" cy="59.4" r="2.3"/><circle cx="56" cy="59.4" r="2.3"/><circle cx="0" cy="66.0" r="2.3"/><circle cx="56" cy="66.0" r="2.3"/></g></mask></defs>
<g mask="url(#perfB)"><rect width="56" height="66" fill="#fbf9f3"/>
<rect x="4" y="4" width="48" height="58" fill="#d2442f"/><circle cx="28" cy="27" r="10" fill="#e9b949"/>
<path d="M4 48 Q16 40 28 46 T52 42 V62 H4Z" fill="#9e2d1d"/>
<text x="8" y="58" font-family="Georgia,serif" font-size="8" fill="#fbf9f3">40¢</text></g>
</svg>
</div>
</div>
<div class="pc-grid">
<div>
<h2 id="pcTitle">Check your <em>email</em></h2>
<p class="note">Your sign-in link is on its way. Tap it on this device and you're in.</p>
</div>
<div class="addr-lines">
<span>To <b id="addr">you@example.com</b></span>
<span>From Northwind</span>
<span>Subject: Your sign-in link</span>
</div>
</div>
<div class="apps">
<a class="app" href="https://mail.google.com/mail/u/0/#search/from%3Anorthwind+newer_than%3A1h" target="_blank" rel="noopener">Open Gmail</a>
<a class="app" href="https://outlook.live.com/mail/0/" target="_blank" rel="noopener">Open Outlook</a>
</div>
<div class="pc-foot">
<button class="link" id="change" type="button">Use a different email</button>
<button class="link" id="resend" type="button" disabled>Resend in 30s</button>
</div>
<p class="pc-msg" id="pcMsg" role="status" aria-live="polite"></p>
</section>
</main>
<svg class="flight" id="flight" aria-hidden="true">
<path class="trail" id="trail" d=""/>
<g id="plane" style="display:none">
<polygon id="wingBot" fill="#ddd5c3" stroke="#c9bfa9" stroke-width="1"/>
<polygon id="wingTop" fill="#f7f4ec" stroke="#d6cdb8" stroke-width="1"/>
<polygon id="flapTop" fill="#ebe4d4" stroke="#cfc5ae" stroke-width="1"/>
<polygon id="flapBot" fill="#d3cab5" stroke="#c2b79f" stroke-width="1"/>
<line id="crease" stroke="#c9bfa9" stroke-width="1"/>
</g>
</svg>
<script>
(() => {
const $ = (id) => document.getElementById(id);
const letter = $("letter"), email = $("email"), send = $("send"), sendLabel = $("sendLabel"), msg = $("msg");
const postcard = $("postcard"), addr = $("addr"), resend = $("resend"), pcMsg = $("pcMsg");
const plane = $("plane"), wingTop = $("wingTop"), wingBot = $("wingBot"), flapTop = $("flapTop"), flapBot = $("flapBot"), crease = $("crease"), trail = $("trail");
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ease = (t) => (t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
// Faint dotted flight route across the mat, from the letter toward the top right.
function drawRoute() {
const r = letter.getBoundingClientRect();
const W = innerWidth, H = innerHeight;
const svg = $("route");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
const sx = r.right - 30, sy = r.top + 40;
$("routePath").setAttribute("d", `M${sx} ${sy} C ${sx + W * .12} ${sy - H * .05}, ${W * .78} ${H * .32}, ${W * .86} ${H * .1} S ${W + 40} ${-20}, ${W + 80} ${-60}`);
}
drawRoute();
addEventListener("resize", drawRoute);
function fail(text) {
email.setAttribute("aria-invalid", "true");
msg.className = "msg err";
msg.textContent = text;
letter.classList.remove("crumple"); void letter.offsetWidth; letter.classList.add("crumple");
email.focus();
}
email.addEventListener("input", () => {
if (email.getAttribute("aria-invalid")) { email.removeAttribute("aria-invalid"); msg.textContent = ""; msg.className = "msg"; }
});
// Fold the letter into a plane (polygon morph), then fly it along a curve.
function shapes(w, h) {
const a = w / 2, b = h / 2;
return {
top: [
[[-a, -b], [a, -b], [a, 0], [-a, 0]],
[[-a * .55, -b * .62], [a * .15, -b * .62], [a * .62, 0], [-a * .55, 0]],
[[-52, -34], [6, -14], [62, 0], [-34, 6]],
],
bot: [
[[-a, 0], [a, 0], [a, b], [-a, b]],
[[-a * .55, 0], [a * .62, 0], [a * .15, b * .62], [-a * .55, b * .62]],
[[-34, 6], [62, 0], [10, 13], [-46, 22]],
],
ftop: [
[[a, -b], [a, -b], [a, -b]],
[[a * .15, -b * .62], [a * .62, 0], [a * .02, -b * .08]],
[[6, -14], [62, 0], [6, -14]],
],
fbot: [
[[a, b], [a, b], [a, b]],
[[a * .15, b * .62], [a * .62, 0], [a * .02, b * .08]],
[[10, 13], [62, 0], [10, 13]],
],
};
}
const lerp = (p, q, t) => [p[0] + (q[0] - p[0]) * t, p[1] + (q[1] - p[1]) * t];
function at(frames, t) {
const seg = t < .5 ? 0 : 1, local = ease(t < .5 ? t * 2 : (t - .5) * 2);
return frames[seg].map((pt, i) => lerp(pt, frames[seg + 1][i], local));
}
const pts = (arr) => arr.map((p) => p.map((n) => n.toFixed(1)).join(",")).join(" ");
const tween = (ms, fn) => new Promise((done) => {
const t0 = performance.now();
const step = (now) => { const t = Math.min(1, (now - t0) / ms); fn(t); t < 1 ? requestAnimationFrame(step) : done(); };
requestAnimationFrame(step);
});
async function foldAndFly() {
const r = letter.getBoundingClientRect();
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
const S = shapes(r.width, r.height);
const W = innerWidth, H = innerHeight;
$("flight").setAttribute("viewBox", `0 0 ${W} ${H}`);
plane.style.display = "";
const place = (x, y, deg, s) => plane.setAttribute("transform", `translate(${x} ${y}) rotate(${deg}) scale(${s})`);
place(cx, cy, 0, 1);
wingTop.setAttribute("points", pts(S.top[0]));
wingBot.setAttribute("points", pts(S.bot[0]));
letter.classList.add("folding");
await tween(820, (t) => {
const tt = at(S.top, t), bb = at(S.bot, t);
wingTop.setAttribute("points", pts(tt));
wingBot.setAttribute("points", pts(bb));
flapTop.setAttribute("points", pts(at(S.ftop, t)));
flapBot.setAttribute("points", pts(at(S.fbot, t)));
crease.setAttribute("x1", tt[3][0]); crease.setAttribute("y1", tt[3][1]);
crease.setAttribute("x2", tt[2][0]); crease.setAttribute("y2", tt[2][1]);
place(cx, cy - 18 * Math.sin(Math.PI * t), -8 * ease(t), 1);
});
letter.classList.add("gone");
// Flight along a cubic bezier with a little dip, leaving a dotted trail.
const p0 = [cx, cy], p1 = [cx + W * .18, cy + H * .16], p2 = [W * .76, H * .02], p3 = [W + 160, -H * .2];
const bez = (t, i) => Math.pow(1 - t, 3) * p0[i] + 3 * Math.pow(1 - t, 2) * t * p1[i] + 3 * (1 - t) * t * t * p2[i] + t * t * t * p3[i];
const dbez = (t, i) => 3 * Math.pow(1 - t, 2) * (p1[i] - p0[i]) + 6 * (1 - t) * t * (p2[i] - p1[i]) + 3 * t * t * (p3[i] - p2[i]);
trail.classList.remove("fade");
let d = `M${cx} ${cy}`;
await tween(1250, (raw) => {
const t = raw * raw * (1.6 - .6 * raw);
const x = bez(t, 0), y = bez(t, 1);
const ang = Math.atan2(dbez(t, 1), dbez(t, 0)) * 180 / Math.PI;
const blend = Math.min(1, raw * 5);
place(x, y, -8 + (ang + 8) * blend, 1 - .35 * t);
d += ` L${x.toFixed(1)} ${y.toFixed(1)}`;
trail.setAttribute("d", d);
});
plane.style.display = "none";
trail.classList.add("fade");
}
let timer;
function countdown(sec) {
clearInterval(timer);
resend.disabled = true;
const tick = () => {
resend.textContent = sec > 0 ? `Resend in ${sec}s` : "Resend link";
if (sec-- <= 0) { resend.disabled = false; clearInterval(timer); }
};
tick();
timer = setInterval(tick, 1000);
}
function showPostcard() {
postcard.classList.add("show");
postcard.focus({ preventScroll: true });
countdown(30);
}
letter.addEventListener("submit", async (e) => {
e.preventDefault();
if (send.getAttribute("aria-busy") === "true") return;
msg.className = "msg"; msg.textContent = "";
email.removeAttribute("aria-invalid");
const v = email.value.trim();
if (!v) return fail("Enter your email address so we know where to send the link.");
if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v)) return fail("That address is missing something. Check it looks like name@example.com.");
send.setAttribute("aria-busy", "true"); send.disabled = true; sendLabel.textContent = "Sending…";
await sleep(650); // your real request goes here
send.removeAttribute("aria-busy"); send.disabled = false; sendLabel.textContent = "Email me a sign-in link";
addr.textContent = v;
if (reduced) { letter.classList.add("gone"); showPostcard(); return; }
await foldAndFly();
showPostcard();
});
$("change").addEventListener("click", () => {
clearInterval(timer);
postcard.classList.remove("show");
pcMsg.textContent = "";
letter.classList.remove("gone");
requestAnimationFrame(() => letter.classList.remove("folding"));
trail.setAttribute("d", "");
email.focus();
email.select();
});
resend.addEventListener("click", () => {
pcMsg.textContent = "Sent again. If it's not there in a minute, check spam or promotions.";
countdown(30);
});
})();
</script>
</body>
</html>