Flow

Screens / Login / Sign in

Paper plane

Passwordless sign-in as air mail on a cutting mat: send the letter and it folds into a paper plane that flies off along a dotted route, then a postcard tells you where the link went.

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

The idea

Your sign-in link is a letter you post by air mail, and the inbox is the destination.

Palette

#26463a
#f7f4ec
#1c2742
#d2442f
#2c55a3
#e9b949

Type and motion

Instrument Serif for headings and the email entry (fallback Palatino/Georgia), system sans for body and buttons, system mono for postal labels and fine print.

Polygon-morph fold of the card into a two-tone plane, then a bezier flight with a dotted trail, then a postcard that springs in with a slight tilt.

Make it yours

  • A pneumatic-tube capsule that whooshes up a brass pipe, for a retro office or fintech brand.
  • A message in a bottle bobbing out to sea on a moonlit beach, for a travel or wellness app.
  • A carrier pigeon taking off from a rooftop with the link tied to its leg, for a playful community app.

Why it's engaging

A tactile, story-like send moment, from stamp and postmark to fold and flight, that turns the dull 'check your email' step into the memorable peak of the flow.

Why it also works better

The postcard shows the exact address, one-tap Open Gmail / Open Outlook buttons, a fix-a-typo action and a visible resend countdown, which is exactly where magic-link flows usually lose people.

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>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>

Guardrails

  • The fold and flight start only after the request succeeds; reduced motion skips straight to the postcard, which gets focus so screen readers announce it.
  • An invalid address triggers one crumple-shake with a plain-words fix, and the field keeps what was typed.
  • 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.