How to Build an Animated Wind Login Page with a Bird Mascot, Lockout and 2FA Code

How to Build an Animated Wind Login Page with a Bird Mascot, Lockout and 2FA Code

This is the most complete login page in our series. It looks playful, with a sunny meadow, a spinning windmill, leaves drifting on the breeze and a little bluebird perched on the form. But underneath, it works like a real sign in flow:

Every letter you type flies into the field like a leaf, and backspaced letters blow away.

A login page in a sunny meadow: a bluebird perched on the form covers its eyes while the password is typed, leaves blow across the sky, a tree and windmill in the background
The finished page: meadow, windmill, drifting leaves and the bluebird.

Try the live demo Sign in with demo@test.com / Demo@123, then enter the code 123456. Or get the password wrong three times to see the storm.

It's three files: HTML, CSS and JavaScript, plus one library, GSAP, for the character animation. If GSAP fails to load, a tiny built-in fallback keeps the page working. This tutorial is long, so feel free to jump between sections. Every code block has a Copy button, and the blocks follow each file from top to bottom.

How it's organised

The JavaScript is split into small modules, each with one job:

Step 1: The HTML

1.1 The head

index.html: head
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wind Login</title>
<!-- If this page is served by Laravel, expose the CSRF token for fetch() calls -->
<!-- <meta name="csrf-token" content="{{ csrf_token() }}"> -->
<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=Fredoka:wght@500;600&family=Nunito:wght@400;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>

<body data-state="idle">

Two Google Fonts: Fredoka, a rounded, friendly display font for headings and the code digits, and Nunito for everything else. The commented-out csrf-token meta tag is a reminder for later. If a framework like Laravel serves this page, it can print a security token here for the JavaScript to send with each request.

1.2 The background scene

index.html: background scene
<!-- ================= BACKGROUND SCENE ================= -->
<div class="scene" aria-hidden="true">
  <div class="sun"></div>

  <svg class="cloud" style="top:8%; width:200px; animation-duration:70s; animation-delay:-20s" viewBox="0 0 200 80"><path fill="#fff" d="M30 70 C5 70 5 40 30 40 C30 15 70 10 80 30 C90 5 140 5 145 35 C175 30 190 70 160 70 Z"/></svg>
  <svg class="cloud" style="top:20%; width:140px; animation-duration:95s; animation-delay:-60s; opacity:.8" viewBox="0 0 200 80"><path fill="#fff" d="M30 70 C5 70 5 40 30 40 C30 15 70 10 80 30 C90 5 140 5 145 35 C175 30 190 70 160 70 Z"/></svg>
  <svg class="cloud" style="top:4%; width:110px; animation-duration:120s; animation-delay:-5s; opacity:.7" viewBox="0 0 200 80"><path fill="#fff" d="M30 70 C5 70 5 40 30 40 C30 15 70 10 80 30 C90 5 140 5 145 35 C175 30 190 70 160 70 Z"/></svg>

  <div class="hills">
    <svg viewBox="0 0 1440 300" preserveAspectRatio="none">
      <path fill="var(--hill-back)" d="M0 150 C240 60 420 120 640 90 C860 60 1080 140 1440 80 V300 H0 Z"/>
      <path fill="var(--hill-front)" d="M0 220 C220 160 480 230 760 190 C1020 150 1240 220 1440 180 V300 H0 Z"/>
    </svg>
  </div>

  <svg class="windmill" viewBox="0 0 90 150">
    <path d="M40 60 L50 60 L58 150 L32 150 Z" fill="#f4f1ea" stroke="#d8d1c4" stroke-width="2"/>
    <rect x="40" y="128" width="10" height="22" rx="3" fill="#b98f64"/>
    <g class="rotor">
      <g fill="#fff" stroke="#d8d1c4" stroke-width="1.5">
        <path d="M45 52 L47 10 L56 12 Z"/><path d="M45 52 L87 54 L85 63 Z"/>
        <path d="M45 52 L43 94 L34 92 Z"/><path d="M45 52 L3 50 L5 41 Z"/>
      </g>
      <circle cx="45" cy="52" r="5" fill="#b98f64"/>
    </g>
  </svg>

  <svg class="tree" viewBox="0 0 210 280">
    <path d="M96 280 C100 220 92 190 100 150 L114 150 C120 190 112 230 120 280 Z" fill="#8a5a3b"/>
    <path d="M104 190 C90 175 76 170 64 160" stroke="#8a5a3b" stroke-width="8" fill="none" stroke-linecap="round"/>
    <g class="crown">
      <circle cx="105" cy="100" r="70" fill="#5aa867"/>
      <circle cx="60" cy="120" r="48" fill="#4f9b5c"/>
      <circle cx="150" cy="118" r="50" fill="#62b26f"/>
      <circle cx="104" cy="58" r="50" fill="#6cc07a"/>
      <circle cx="130" cy="80" r="22" fill="#7fcc8b" opacity=".7"/>
    </g>
  </svg>

  <div class="grass" id="grass"></div>
</div>

<canvas id="windCanvas" aria-hidden="true"></canvas>

<div class="storm" id="storm" aria-hidden="true"></div>
<div class="storm-clouds" id="stormClouds" aria-hidden="true">
  <svg viewBox="0 0 1440 300" preserveAspectRatio="none">
    <path fill="#3b4560" d="M0 0 H1440 V170 C1380 230 1300 200 1260 180 C1220 250 1120 240 1080 200 C1020 260 920 250 880 200 C820 250 720 240 690 190 C640 250 540 240 500 200 C440 260 340 240 310 190 C260 240 170 230 140 190 C90 230 30 220 0 190 Z"/>
    <path fill="#2b3348" d="M0 0 H1440 V110 C1360 160 1290 130 1240 120 C1180 170 1080 160 1040 130 C980 170 900 160 860 120 C800 170 700 160 660 120 C600 170 500 160 460 120 C400 170 300 160 260 120 C200 160 120 150 80 120 C50 140 20 140 0 130 Z"/>
  </svg>
</div>
<div class="flash" id="flash" aria-hidden="true"></div>

The meadow is made of SVG pieces:

Then come the layers that only appear in a lockout: a dark .storm overlay, SVG storm clouds and a white .flash for lightning. Everything here is aria-hidden="true".

1.3 Live regions and the bird

index.html: live regions and bird
<!-- ================= LIVE REGIONS ================= -->
<div id="srStatus" class="sr-only" aria-live="polite" aria-atomic="true"></div>
<div id="srAlert" class="sr-only" role="alert" aria-atomic="true"></div>

<!-- ================= LOGIN ================= -->
<main class="stage">
  <div class="card-wrap" id="cardWrap">

    <!-- Mascot: a little bluebird perched on the card -->
    <div class="bird" id="bird" aria-hidden="true">
      <svg viewBox="0 0 160 150">
        <g id="birdBody">
          <!-- tail -->
          <path d="M112 112 C130 116 146 126 154 140 C138 136 124 132 108 126 Z" fill="#3f6fd0"/>
          <!-- body -->
          <circle cx="80" cy="82" r="52" fill="#5b8def"/>
          <path d="M40 60 C50 36 70 28 92 30" stroke="#86adf5" stroke-width="7" fill="none" stroke-linecap="round" opacity=".7"/>
          <ellipse cx="80" cy="104" rx="36" ry="27" fill="#ffe8c2"/>
          <!-- tuft -->
          <path d="M74 32 C70 18 76 10 82 8 C80 16 84 22 86 32 Z" fill="#3f6fd0"/>
          <path d="M84 32 C86 22 94 18 100 18 C96 24 94 28 92 34 Z" fill="#4a7de0"/>
          <!-- cheeks -->
          <ellipse cx="50" cy="88" rx="9" ry="5" fill="#ff9fb1" opacity=".75"/>
          <ellipse cx="110" cy="88" rx="9" ry="5" fill="#ff9fb1" opacity=".75"/>
          <!-- eyes -->
          <g class="eye"><circle cx="62" cy="68" r="12" fill="#fff"/>
            <g id="pupilL"><circle cx="62" cy="68" r="6.5" fill="#1d2633"/><circle cx="60" cy="65.5" r="2.2" fill="#fff"/></g></g>
          <g class="eye"><circle cx="98" cy="68" r="12" fill="#fff"/>
            <g id="pupilR"><circle cx="98" cy="68" r="6.5" fill="#1d2633"/><circle cx="96" cy="65.5" r="2.2" fill="#fff"/></g></g>
          <!-- beak -->
          <path id="beak" d="M71 80 Q80 75 89 80 L80 93 Z" fill="#ffad33"/>
          <!-- feet gripping the card edge -->
          <g fill="#f59e2b">
            <path d="M58 128 h16 v6 a3 3 0 0 1 -3 3 h-10 a3 3 0 0 1 -3 -3 Z"/>
            <path d="M86 128 h16 v6 a3 3 0 0 1 -3 3 h-10 a3 3 0 0 1 -3 -3 Z"/>
          </g>
          <!-- wings (rotated by JS around the shoulders) -->
          <path id="wingL" d="M36 78 C20 84 18 114 26 130 C32 140 46 138 48 126 C52 108 50 88 36 78 Z" fill="#3f6fd0"/>
          <path id="wingR" d="M124 78 C140 84 142 114 134 130 C128 140 114 138 112 126 C108 108 110 88 124 78 Z" fill="#3f6fd0"/>
        </g>
      </svg>
    </div>

Live regions are how the page talks to screen reader users. Text placed in #srStatus (aria-live="polite") is read when the user pauses. Text in #srAlert (role="alert") interrupts straight away, for errors. Both are visually hidden with the .sr-only class.

The bird is one SVG with ids on the parts JavaScript animates: the pupils (#pupilL, #pupilR), the wings (#wingL, #wingR) and the whole body group. Its feet are drawn at the bottom of the SVG, so it looks like it's gripping the top edge of the card.

1.4 The sign in form

index.html: sign in form
    <section class="card" id="card" aria-labelledby="loginTitle">

      <!-- ---------- STEP 1: credentials ---------- -->
      <div class="panel" id="loginPanel">
        <h1 id="loginTitle" class="blow">Welcome back</h1>
        <p class="sub blow">Let the breeze carry you in</p>

        <form id="loginForm" novalidate>
          <fieldset id="loginFields">
            <legend class="sr-only">Sign in</legend>

            <div class="field blow">
              <label for="email">Email</label>
              <div class="control">
                <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="m4 7 8 6 8-6"/></svg>
                <input id="email" name="email" type="email" inputmode="email" autocomplete="username" placeholder="you@example.com"
                       aria-describedby="emailHint" required>
                <span class="status" id="emailStatus" aria-hidden="true">
                  <svg class="ok" viewBox="0 0 26 26"><path d="M13 24 V13" stroke="#3f8f4f" stroke-width="2.4" stroke-linecap="round"/><path d="M13 14 C6 14 3 9 4 4 C10 4 13 8 13 14 Z" fill="#6cc07a"/><path d="M13 12 C18 12 22 9 22 5 C17 5 13 7 13 12 Z" fill="#8bd497"/></svg>
                  <svg class="bad" viewBox="0 0 26 26"><path d="M6 6 C14 4 22 10 20 20 C12 22 4 16 6 6 Z" fill="#c9965a"/><path d="M7 7 L19 19" stroke="#8a5a3b" stroke-width="1.6"/></svg>
                </span>
              </div>
              <p class="hint" id="emailHint">We'll never share your email.</p>
            </div>

            <div class="field blow">
              <label for="password">Password</label>
              <div class="control">
                <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="4" y="10" width="16" height="11" rx="3"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/></svg>
                <input id="password" name="password" type="password" autocomplete="current-password" placeholder="Your password"
                       aria-describedby="pwHint meterLabel capsWarn" required>
                <button type="button" class="toggle" id="togglePw" aria-label="Show password" aria-pressed="false" aria-controls="password">
                  <svg class="eye-open" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/></svg>
                  <svg class="eye-closed" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 3l18 18"/><path d="M10.6 5.1A10 10 0 0 1 12 5c6.5 0 10 7 10 7a17 17 0 0 1-3.2 4.2M6.6 6.6A17 17 0 0 0 2 12s3.5 7 10 7a9.8 9.8 0 0 0 5.4-1.6"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>
                </button>
              </div>
              <span class="caps" id="capsWarn" aria-live="polite">⇪ Caps Lock is on</span>

              <div class="strength" aria-hidden="false">
                <svg class="windsock" viewBox="0 0 58 34" aria-hidden="true">
                  <rect x="3" y="2" width="3" height="32" rx="1.5" fill="#8a9aa3"/>
                  <g class="sock" id="sock">
                    <g class="flutter">
                      <path d="M6 6 L54 11 L54 21 L6 26 Z" fill="#fff" stroke="#cfd9de"/>
                      <path d="M6 6 L18 7.3 L18 24.7 L6 26 Z" fill="#e2574c"/>
                      <path d="M30 8.5 L42 9.8 L42 22.2 L30 23.5 Z" fill="#e2574c"/>
                    </g>
                  </g>
                </svg>
                <div class="meter">
                  <div class="bars" aria-hidden="true"><span class="bar"><i></i></span><span class="bar"><i></i></span><span class="bar"><i></i></span><span class="bar"><i></i></span></div>
                  <div class="meter-label" id="meterLabel">Wind strength: calm</div>
                </div>
              </div>
              <p class="hint" id="pwHint"></p>
            </div>

            <div class="row blow">
              <label class="check"><input type="checkbox" id="remember" name="remember"> Remember me</label>
              <!-- Point this at your reset page, e.g. Laravel: route('password.request') -->
              <a href="#">Forgot password?</a>
            </div>

            <div class="attempts blow" id="attempts">
              <span>Attempts</span>
              <span class="attempt-icons" id="attemptIcons" aria-hidden="true"></span>
              <span class="sr-only" id="attemptsText"></span>
            </div>

            <!-- lockout (storm) -->
            <div class="lock" id="lockPanel" aria-live="polite">
              <div class="title">
                <svg class="tornado" viewBox="0 0 24 24" aria-hidden="true"><g fill="none" stroke="#c8f1ff" stroke-width="2.2" stroke-linecap="round"><path d="M3 4h18"/><path d="M5 8h14"/><path d="M7 12h10"/><path d="M9 16h6"/><path d="M11 20h2"/></g></svg>
                Storm lockout
              </div>
              <div class="clock" id="lockClock">1:00</div>
              <div class="track"><i id="lockBar"></i></div>
              <p>Too many tries. The wind will calm down soon.</p>
            </div>

            <button type="submit" class="btn blow" id="loginBtn">
              <svg class="pinwheel" viewBox="0 0 26 26" aria-hidden="true"><g>
                <path d="M13 13 L13 2 C18 2 20 7 13 13Z" fill="#fff"/><path d="M13 13 L24 13 C24 18 19 20 13 13Z" fill="#ffe08a"/>
                <path d="M13 13 L13 24 C8 24 6 19 13 13Z" fill="#fff"/><path d="M13 13 L2 13 C2 8 7 6 13 13Z" fill="#ffe08a"/>
                <circle cx="13" cy="13" r="2" fill="#1f7a6e"/></g></svg>
              <span class="label">Log in</span>
            </button>
          </fieldset>
        </form>

        <!-- Point this at your sign up page, e.g. Laravel: route('register') -->
        <p class="alt blow">New here? <a href="#">Create an account</a></p>
        <p class="demo-note blow">Demo: <code>demo@test.com</code> / <code>Demo@123</code></p>
      </div>

A lot of good form practice is packed in here:

The class blow marks every piece that gets blown away when the panel changes.

1.5 The verification code step

index.html: code step
      <!-- ---------- STEP 2: one-time code ---------- -->
      <div class="panel" id="otpPanel" hidden>
        <h1 id="otpTitle" class="blow2" tabindex="-1">Check your inbox</h1>
        <p class="sub blow2" id="otpSub">We blew a 6-digit code to your email.</p>
        <form id="otpForm" novalidate>
          <fieldset>
            <legend class="sr-only">Enter the 6-digit verification code</legend>
            <div class="otp-boxes blow2" id="otpBoxes" role="group" aria-label="Verification code"></div>
            <p class="hint blow2" id="otpHint" style="text-align:center"></p>
            <div class="otp-meta blow2">
              <button type="button" class="linkbtn" id="backBtn">← Use another account</button>
              <button type="button" class="linkbtn" id="resendBtn" disabled>Resend in 0:30</button>
            </div>
            <button type="submit" class="btn blow2" id="otpBtn">
              <svg class="pinwheel" viewBox="0 0 26 26" aria-hidden="true"><g>
                <path d="M13 13 L13 2 C18 2 20 7 13 13Z" fill="#fff"/><path d="M13 13 L24 13 C24 18 19 20 13 13Z" fill="#ffe08a"/>
                <path d="M13 13 L13 24 C8 24 6 19 13 13Z" fill="#fff"/><path d="M13 13 L2 13 C2 8 7 6 13 13Z" fill="#ffe08a"/>
                <circle cx="13" cy="13" r="2" fill="#1f7a6e"/></g></svg>
              <span class="label">Verify</span>
            </button>
          </fieldset>
        </form>
        <p class="demo-note blow2">Demo code: <code>123456</code></p>
      </div>
    </section>
  </div>
</main>
The Check your inbox step with six code boxes, three already filled with 1, 2 and 3
Step two: digits fly into the boxes as you type.

The second panel starts hidden. The six digit boxes are created by JavaScript, so the container is empty here. The title has tabindex="-1" so the page can move focus to it if needed. role="group" with a label tells screen readers the boxes belong together.

1.6 The cloud gate and scripts

index.html: success gate and scripts
<!-- ================= SUCCESS: cloud gate ================= -->
<div class="gate" id="gate" aria-hidden="true">
  <div class="half l"><svg viewBox="0 0 560 1000" preserveAspectRatio="none"><path fill="#fff" d="M0 0 H500 C560 60 520 120 540 180 C600 240 520 300 545 370 C590 440 515 500 540 570 C595 640 520 700 545 770 C590 840 515 900 520 1000 H0 Z"/></svg></div>
  <div class="half r"><svg viewBox="0 0 560 1000" preserveAspectRatio="none"><path fill="#fff" d="M560 0 H60 C0 60 40 120 20 180 C-40 240 40 300 15 370 C-30 440 45 500 20 570 C-35 640 40 700 15 770 C-30 840 45 900 40 1000 H560 Z"/></svg></div>
</div>
<div class="welcome" id="welcome" aria-hidden="true">
  <div>
    <svg class="pin" viewBox="0 0 26 26" aria-hidden="true"><g>
      <path d="M13 13 L13 2 C18 2 20 7 13 13Z" fill="#2f9e8f"/><path d="M13 13 L24 13 C24 18 19 20 13 13Z" fill="#ffc94a"/>
      <path d="M13 13 L13 24 C8 24 6 19 13 13Z" fill="#2f9e8f"/><path d="M13 13 L2 13 C2 8 7 6 13 13Z" fill="#ffc94a"/>
      <circle cx="13" cy="13" r="2" fill="#1f3340"/></g></svg>
    <h2>Welcome aboard!</h2>
    <p id="welcomeText">Riding the breeze to your dashboard…</p>
    <button class="replay" id="replayBtn" type="button">Replay demo</button>
  </div>
</div>

<!-- GSAP (cdnjs) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script src="script.js"></script>
</body>
</html>

The gate is two SVG cloud halves, one for each side of the screen, with wavy inner edges that meet in the middle. GSAP is loaded from cdnjs before our script.js.

Step 2: The CSS

2.1 Theme tokens

style.css: theme tokens
/* =====================================================================
   THEME TOKENS (overwritten at runtime from CONFIG.colors)
   ===================================================================== */
:root {
  --sky-top: #8fd3ff;
  --sky-mid: #cdeeff;
  --sky-bottom: #ffe9d6;
  --hill-back: #9fd8a4;
  --hill-front: #6cc07a;
  --accent: #2f9e8f;
  --accent-dark: #1f7a6e;
  --accent-soft: rgba(47, 158, 143, .16);
  --danger: #e2574c;
  --warning: #f2a33a;
  --success: #3fae5a;
  --ink: #1f3340;
  --ink-soft: #5d7380;
  --card: rgba(255, 255, 255, .82);
  --card-border: rgba(255, 255, 255, .9);
  --field: #f4fbfa;
  --field-border: #d6e8e6;
  --radius: 24px;
}

* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { min-height: 100%; }
body {
  font-family: 'Nunito', system-ui, sans-serif;
  color: var(--ink);
  background: linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) 45%, var(--sky-bottom) 100%);
  background-attachment: fixed;
  overflow-x: hidden;
}
.sr-only {
  position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

All colours are CSS variables. JavaScript overwrites them from CONFIG.colors at start up, so you can re-theme the whole page from one JavaScript object. background-attachment: fixed keeps the sky gradient still while the page scrolls. The .sr-only class is the standard way to hide content visually while keeping it available to screen readers.

2.2 The background scene and storm layers

style.css: background scene
/* =====================================================================
   BACKGROUND SCENE
   ===================================================================== */
.scene { position: fixed; inset: 0; z-index: 0; pointer-events: none; overflow: hidden; }
.sun {
  position: absolute; top: 7%; right: 11%; width: 110px; height: 110px; border-radius: 50%;
  background: radial-gradient(circle, #fffbe0 0%, #ffe68a 55%, rgba(255, 220, 110, 0) 72%);
  animation: sunPulse 6s ease-in-out infinite alternate;
}
@keyframes sunPulse { to { transform: scale(1.08); opacity: .9; } }

.cloud { position: absolute; left: 0; opacity: .95; will-change: transform; animation: cloudDrift linear infinite; }
@keyframes cloudDrift { from { transform: translateX(-260px); } to { transform: translateX(calc(100vw + 60px)); } }

.hills { position: absolute; left: 0; right: 0; bottom: 0; height: 30vh; min-height: 180px; }
.hills svg { position: absolute; bottom: 0; width: 100%; height: 100%; }

.tree { position: absolute; left: max(2vw, 10px); bottom: 12vh; width: 210px; height: 280px; }
.tree .crown { transform-box: fill-box; transform-origin: 50% 100%; animation: crownSway 4.5s ease-in-out infinite alternate; }
@keyframes crownSway { from { transform: rotate(-2.5deg) skewX(-2deg); } to { transform: rotate(3.5deg) skewX(3deg); } }

.grass { position: absolute; left: 0; right: 0; bottom: 0; height: 70px; display: flex; justify-content: space-around; align-items: flex-end; }
.blade {
  width: 6px; border-radius: 6px 6px 0 0; background: linear-gradient(#5bb36a, #3f8f4f);
  transform-origin: 50% 100%; animation: bladeSway ease-in-out infinite alternate;
}
@keyframes bladeSway { from { transform: rotate(-4deg); } to { transform: rotate(16deg); } }

.windmill { position: absolute; right: 8vw; bottom: 22vh; width: 90px; height: 150px; }
.windmill .rotor { transform-box: fill-box; transform-origin: 50% 50%; animation: spin 4s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }

#windCanvas { position: fixed; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }

/* storm layer (lockout) */
.storm { position: fixed; inset: 0; z-index: 2; pointer-events: none; opacity: 0;
  background: linear-gradient(180deg, rgba(30, 38, 58, .88), rgba(48, 58, 80, .72) 55%, rgba(30, 36, 50, .8)); }
.flash { position: fixed; inset: 0; z-index: 3; pointer-events: none; opacity: 0; background: #f4f8ff; }
.storm-clouds { position: fixed; left: 0; right: 0; top: 0; height: 40vh; z-index: 2; pointer-events: none; opacity: 0; transform: translateY(-100%); }
.storm-clouds svg { width: 100%; height: 100%; }

SVG groups such as the windmill rotor and the tree crown use transform-box: fill-box. That makes transform-origin refer to the group's own box, so the rotor spins around its hub and the crown sways from its base. The storm layers start at opacity: 0, and the storm clouds wait above the screen (translateY(-100%)), ready to roll in.

2.3 The card and fields

style.css: card and fields
/* =====================================================================
   LAYOUT + CARD
   ===================================================================== */
.stage {
  position: relative; z-index: 5; min-height: 100vh; min-height: 100dvh;
  display: grid; place-items: center; padding: 120px 16px 40px;
}
.card-wrap { position: relative; width: min(410px, 100%); }
.card {
  position: relative;
  background: var(--card);
  border: 1px solid var(--card-border);
  border-radius: var(--radius);
  padding: 40px 30px 26px;
  box-shadow: 0 30px 60px -20px rgba(24, 60, 80, .35), 0 2px 0 rgba(255, 255, 255, .6) inset;
  backdrop-filter: blur(14px) saturate(140%);
  -webkit-backdrop-filter: blur(14px) saturate(140%);
  transition: background-color .6s ease, box-shadow .6s ease;
}
body[data-state="locked"] .card { background: rgba(232, 238, 248, .9); }

.panel[hidden] { display: none !important; }
h1 { font-family: 'Fredoka', 'Nunito', sans-serif; font-weight: 600; font-size: 28px; text-align: center; letter-spacing: .2px; }
.sub { text-align: center; color: var(--ink-soft); font-size: 14px; margin: 4px 0 22px; }

fieldset { border: 0; min-width: 0; }
.field { position: relative; margin-bottom: 16px; }
.field > label { display: block; font-weight: 700; font-size: 13.5px; margin-bottom: 6px; }
.control { position: relative; }
.control .icon {
  position: absolute; left: 14px; top: 50%; width: 18px; height: 18px; margin-top: -9px; color: var(--ink-soft); pointer-events: none;
}
.control input {
  width: 100%; height: 48px; padding: 0 48px 0 42px;
  border: 2px solid var(--field-border); border-radius: 14px; background: var(--field);
  font: 600 15px 'Nunito', sans-serif; color: var(--ink); outline: none;
  transition: border-color .2s, box-shadow .2s, background-color .2s;
}
.control input::placeholder { color: #9bb0b8; font-weight: 400; }
.control input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 4px var(--accent-soft); }
.control input[aria-invalid="true"] { border-color: var(--danger); box-shadow: 0 0 0 4px rgba(226, 87, 76, .14); }
.control input.valid { border-color: var(--success); }
.control input:disabled { opacity: .55; cursor: not-allowed; }

The card is frosted glass: white at 82% opacity with backdrop-filter: blur(14px). When the body has data-state="locked", the card turns a cool grey-blue to match the storm. The state machine sets that attribute, so CSS can react to the app's state without extra classes.

Inputs show their state through attributes: [aria-invalid="true"] turns red, and the .valid class turns green.

2.4 Status icons, hints, Caps Lock and the windsock

style.css: status, hints and strength
/* email status: sprout (valid) / wilted leaf (invalid) */
.status { position: absolute; right: 12px; top: 50%; width: 26px; height: 26px; margin-top: -13px; pointer-events: none; }
.status svg { position: absolute; inset: 0; opacity: 0; transform: scale(.2) rotate(-40deg); transition: transform .45s cubic-bezier(.3, 1.6, .5, 1), opacity .25s; }
.status.ok svg.ok, .status.bad svg.bad { opacity: 1; transform: none; }

.toggle {
  position: absolute; right: 6px; top: 50%; width: 38px; height: 38px; margin-top: -19px;
  border: 0; border-radius: 10px; background: transparent; color: var(--ink-soft); cursor: pointer; display: grid; place-items: center;
}
.toggle:hover { background: var(--accent-soft); color: var(--accent-dark); }
.toggle svg { width: 22px; height: 22px; }
.toggle .eye-closed { display: none; }
.toggle[aria-pressed="true"] .eye-open { display: none; }
.toggle[aria-pressed="true"] .eye-closed { display: block; }

.hint { font-size: 12.5px; min-height: 18px; margin-top: 5px; color: var(--ink-soft); }
.hint.error { color: var(--danger); font-weight: 700; }
.hint.good { color: var(--success); font-weight: 700; }

.caps {
  position: absolute; right: 0; top: -3px;
  display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 30px;
  background: #fff4e0; color: #9a5b00; font-size: 12px; font-weight: 800;
  opacity: 0; transform: translateY(-4px); transition: opacity .2s, transform .2s;
}
.caps.on { opacity: 1; transform: none; }

/* strength meter: windsock + 4 bars */
.strength { display: flex; align-items: center; gap: 12px; margin-top: 8px; }
.windsock { width: 58px; height: 34px; flex: none; }
.windsock .sock { transform-box: fill-box; transform-origin: 0% 50%; transform: scaleX(.12); transition: transform .6s cubic-bezier(.3, 1.5, .5, 1); }
.windsock .flutter { transform-box: fill-box; transform-origin: 0% 50%; animation: flutter var(--flutter, 1.4s) ease-in-out infinite alternate; }
@keyframes flutter { from { transform: skewY(-4deg); } to { transform: skewY(5deg); } }
.meter { flex: 1; }
.bars { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; }
.bar { height: 7px; border-radius: 6px; background: #e3eef0; overflow: hidden; }
.bar i { display: block; height: 100%; border-radius: inherit; transform: scaleX(0); transform-origin: left; transition: transform .45s ease, background-color .3s; }
.meter-label { font-size: 12px; font-weight: 700; color: var(--ink-soft); margin-top: 4px; }

.row { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 13.5px; margin: 4px 0 14px; }
.check { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; }
.check input { width: 17px; height: 17px; accent-color: var(--accent); }
a { color: var(--accent-dark); font-weight: 700; text-decoration: none; border-radius: 4px; }
a:hover { text-decoration: underline; }
:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
.control input:focus-visible { outline: none; }

The sprout and wilted leaf both start invisible, small and rotated. When the status gets .ok or .bad, the matching icon springs into place with an overshooting curve, cubic-bezier(.3, 1.6, .5, 1), so it looks like it pops up.

The windsock is scaleX(.12) when the password is empty, and JavaScript widens it as the password gets stronger. Inside, .flutter skews back and forth at a speed set by the --flutter variable: a strong password makes it flap faster, like in strong wind.

2.5 Attempts, button, lockout panel and code boxes

style.css: attempts, button, lockout and OTP
/* attempts */
.attempts { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 14px; font-size: 12.5px; color: var(--ink-soft); font-weight: 700; }
.attempt-icons { display: flex; gap: 6px; }
.attempt-icons svg { width: 22px; height: 22px; }
.attempt-icons svg.gone { visibility: hidden; }

/* button + pinwheel loader */
.btn {
  position: relative; width: 100%; height: 50px; border: 0; border-radius: 14px; cursor: pointer;
  font: 800 16px 'Nunito', sans-serif; color: #fff; letter-spacing: .3px;
  background: linear-gradient(135deg, var(--accent), var(--accent-dark));
  box-shadow: 0 12px 24px -10px var(--accent-dark);
  display: flex; align-items: center; justify-content: center; gap: 10px; overflow: hidden;
  transition: transform .15s ease, filter .2s;
}
.btn:hover { filter: brightness(1.06); }
.btn:active { transform: scale(.98); }
.btn:disabled { cursor: not-allowed; filter: grayscale(.4) brightness(.95); }
.btn .pinwheel { width: 26px; height: 26px; display: none; }
.btn .pinwheel g { transform-box: fill-box; transform-origin: 50% 50%; animation: spin .7s linear infinite; }
.btn[aria-busy="true"] .pinwheel { display: block; }
.btn::after { /* soft gust sweep while loading */
  content: ''; position: absolute; inset: 0; opacity: 0;
  background: linear-gradient(100deg, transparent 20%, rgba(255, 255, 255, .35) 50%, transparent 80%);
  transform: translateX(-100%);
}
.btn[aria-busy="true"]::after { opacity: 1; animation: sweep 1.1s ease-in-out infinite; }
@keyframes sweep { to { transform: translateX(100%); } }

.alt { text-align: center; font-size: 13.5px; margin-top: 16px; color: var(--ink-soft); }

/* lockout panel */
.lock { display: none; margin: 4px 0 14px; padding: 14px 16px; border-radius: 16px; background: #2b3550; color: #e9f0ff; text-align: center; }
body[data-state="locked"] .lock { display: block; }
.lock .title { font-family: 'Fredoka', sans-serif; font-size: 17px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.lock .clock { font-family: 'Fredoka', sans-serif; font-size: 34px; letter-spacing: 1px; margin: 4px 0; font-variant-numeric: tabular-nums; }
.lock .track { height: 6px; border-radius: 6px; background: rgba(255, 255, 255, .15); overflow: hidden; }
.lock .track i { display: block; height: 100%; background: linear-gradient(90deg, #8fd3ff, #c8f1ff); transform-origin: left; }
.lock p { font-size: 12.5px; opacity: .85; margin-top: 8px; }
.tornado { width: 22px; height: 22px; }
.tornado g { transform-box: fill-box; transform-origin: 50% 50%; animation: wobble .6s ease-in-out infinite alternate; }
@keyframes wobble { from { transform: skewX(-10deg); } to { transform: skewX(10deg); } }

/* OTP */
.otp-boxes { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 6px 0 10px; }
.otp-boxes input {
  width: 100%; aspect-ratio: 1 / 1.15; min-height: 46px; text-align: center;
  font: 700 24px 'Fredoka', sans-serif; color: var(--ink);
  border: 2px solid var(--field-border); border-radius: 14px 14px 14px 4px; background: var(--field); outline: none;
  transition: border-color .2s, box-shadow .2s, background-color .2s;
}
.otp-boxes input:focus { border-color: var(--accent); background: #fff; box-shadow: 0 0 0 4px var(--accent-soft); }
.otp-boxes input.filled { border-color: var(--accent); background: #effaf7; }
.otp-boxes input[aria-invalid="true"] { border-color: var(--danger); }
.otp-meta { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; font-size: 13px; margin-bottom: 14px; }
.linkbtn { background: none; border: 0; font: 700 13.5px 'Nunito', sans-serif; color: var(--accent-dark); cursor: pointer; padding: 2px 4px; border-radius: 4px; }
.linkbtn:disabled { color: var(--ink-soft); cursor: default; }
.demo-note { text-align: center; font-size: 12.5px; color: var(--ink-soft); margin-top: 10px; }
.demo-note code { background: var(--accent-soft); padding: 1px 6px; border-radius: 6px; font-weight: 800; color: var(--accent-dark); }

2.6 The bird, flying letters and feathers

style.css: mascot and flyers
/* =====================================================================
   MASCOT (bird perched on the card)
   ===================================================================== */
.bird { position: absolute; left: 50%; top: -106px; width: 130px; height: 122px; margin-left: -65px; z-index: 6; pointer-events: none; }
.bird svg { width: 100%; height: 100%; overflow: visible; }
.bird .eye { transform-box: fill-box; transform-origin: 50% 50%; animation: blink 5s infinite; }
@keyframes blink { 0%, 94%, 100% { transform: scaleY(1); } 96% { transform: scaleY(.08); } }

/* flying letters (leaves) */
.flyer {
  position: fixed; left: 0; top: 0; z-index: 30; pointer-events: none;
  width: 28px; height: 28px; display: grid; place-items: center;
  font: 800 14px 'Nunito', sans-serif; color: #fff; text-shadow: 0 1px 1px rgba(0, 0, 0, .25);
  will-change: transform, opacity;
}
.flyer::before {
  content: ''; position: absolute; inset: 0; z-index: -1;
  background: var(--leaf, #6cc07a); border-radius: 0 80% 0 80%;
  box-shadow: inset -3px -3px 0 rgba(0, 0, 0, .12);
}
.feather { position: fixed; left: 0; top: 0; z-index: 30; width: 14px; height: 6px; border-radius: 50%; background: #5b8def; pointer-events: none; }

The bird sits above the card with top: -106px and never blocks clicks (pointer-events: none). A flying letter is a small box whose ::before is a leaf shape. border-radius: 0 80% 0 80% rounds two opposite corners only, which makes a classic leaf. The leaf colour comes from --leaf, set randomly per letter.

2.7 The cloud gate, responsive rules and reduced motion

style.css: gate, responsive and motion
/* =====================================================================
   SUCCESS: cloud gate
   ===================================================================== */
.gate { position: fixed; inset: 0; z-index: 40; pointer-events: none; visibility: hidden; }
.gate .half { position: absolute; top: 0; bottom: 0; width: 56%; }
.gate .half.l { left: 0; transform: translateX(-102%); }
.gate .half.r { right: 0; transform: translateX(102%); }
.gate .half svg { width: 100%; height: 100%; display: block; }
.welcome {
  position: fixed; inset: 0; z-index: 41; display: grid; place-items: center; text-align: center; pointer-events: none;
  opacity: 0; color: var(--ink);
}
.welcome h2 { font-family: 'Fredoka', sans-serif; font-size: clamp(28px, 7vw, 46px); }
.welcome p { color: var(--ink-soft); margin-top: 6px; font-weight: 700; }
.welcome .pin { width: 64px; height: 64px; margin: 0 auto 12px; }
.welcome .pin g { transform-box: fill-box; transform-origin: 50% 50%; animation: spin 1s linear infinite; }
.welcome a, .welcome button { pointer-events: auto; }
.replay { margin-top: 18px; padding: 10px 20px; border: 0; border-radius: 12px; background: var(--accent); color: #fff; font: 800 14px 'Nunito'; cursor: pointer; display: none; }

/* =====================================================================
   RESPONSIVE + REDUCED MOTION
   ===================================================================== */
@media (max-width: 720px) {
  .tree { width: 150px; height: 200px; left: -30px; }
  .windmill { right: 2vw; transform: scale(.75); transform-origin: bottom right; }
}
@media (max-width: 420px) {
  .card { padding: 36px 18px 22px; }
  h1 { font-size: 24px; }
  .otp-boxes { gap: 6px; }
  .otp-boxes input { font-size: 20px; }
  .tree, .windmill { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}

The two gate halves wait just off screen (translateX(±102%)). On small screens the tree and windmill shrink, and on phones they disappear, so they don't crowd the form. Reduced motion makes every CSS animation and transition finish almost instantly.

Step 3: The JavaScript

3.1 CONFIG

script.js: CONFIG
/* =====================================================================
   CONFIG – theme colours, timings, limits
   ===================================================================== */
const CONFIG = {
  colors: {
    skyTop: '#8fd3ff', skyMid: '#cdeeff', skyBottom: '#ffe9d6',
    hillBack: '#9fd8a4', hillFront: '#6cc07a',
    accent: '#2f9e8f', accentDark: '#1f7a6e',
    danger: '#e2574c', warning: '#f2a33a', success: '#3fae5a',
    leaves: ['#6cc07a', '#8bd497', '#f2b84b', '#e98a3c', '#d9634a', '#a7cf5b'],
    strength: ['#e2574c', '#f2a33a', '#a7c94a', '#3fae5a']
  },
  timings: {
    emailDebounce: 450,      // ms before live email validation runs
    letterFlight: 0.75,      // s for a typed letter to fly in
    fakeLatency: 1300,       // ms the mock API takes
    resendSeconds: 30,       // OTP resend cooldown
    redirectDelay: 2600      // ms after the reveal before navigating
  },
  wind: { base: 1, gust: 3.2, storm: 4.2, leafCount: 34 },
  maxAttempts: 3,
  lockoutSeconds: 60,
  otpLength: 6,
  redirectUrl: '/dashboard',
  demoMode: true,            // true: never navigate after success (set false in your real app)
  storageKeys: { lockUntil: 'windLogin.lockUntil', failures: 'windLogin.failures', email: 'windLogin.email' },
  demo: { email: 'demo@test.com', password: 'Demo@123', otp: '123456' }
};

Everything you might want to tune is here:

demoMode: true keeps the demo from navigating after success. Set it to false in a real app so the user is redirected to redirectUrl. The demo credentials live here too. Remove them in production.

3.2 Utilities, the GSAP fallback and announcements

script.js: utilities
/* =====================================================================
   UTILITIES
   ===================================================================== */
const $ = (s, r = document) => r.querySelector(s);
const $$ = (s, r = document) => [...r.querySelectorAll(s)];
const sleep = ms => new Promise(r => setTimeout(r, ms));
const rand = (a, b) => a + Math.random() * (b - a);
const pick = arr => arr[(Math.random() * arr.length) | 0];
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
const RM = () => reduceMotion.matches;
const HAS_GSAP = typeof window.gsap !== 'undefined';

// Minimal fallback so the page still works if the CDN is blocked (no tweening, end states only)
if (!HAS_GSAP) {
  const apply = (targets, v) => {
    const list = typeof targets === 'string' ? $$(targets) : (targets.length !== undefined ? [...targets] : [targets]);
    list.forEach(el => {
      if (!el || !el.style) return;
      if ('opacity' in v || 'autoAlpha' in v) el.style.opacity = v.opacity ?? v.autoAlpha;
      if ('autoAlpha' in v) el.style.visibility = v.autoAlpha ? 'visible' : 'hidden';
      const t = [];
      if ('x' in v || 'y' in v) t.push(`translate(${typeof v.x === 'string' ? v.x : (v.x || 0) + 'px'}, ${typeof v.y === 'string' ? v.y : (v.y || 0) + 'px'})`);
      if ('rotation' in v) t.push(`rotate(${v.rotation}deg)`);
      if ('scale' in v) t.push(`scale(${v.scale})`);
      if (t.length) el.style.transform = t.join(' ');
    });
    if (v.onComplete) setTimeout(v.onComplete, 0);
  };
  const tl = () => { const o = { to: (t, v) => (apply(t, v), o), from: () => o, fromTo: (t, a, b) => (apply(t, b), o), set: (t, v) => (apply(t, v), o), call: f => (setTimeout(f, 0), o), add: () => o, kill: () => o, then: f => Promise.resolve().then(f) }; return o; };
  window.gsap = { to: (t, v) => (apply(t, v), tl()), from: () => tl(), fromTo: (t, a, b) => (apply(t, b), tl()), set: (t, v) => apply(t, v), timeline: tl, killTweensOf: () => {}, utils: { random: rand }, globalTimeline: { timeScale() {} } };
}

const store = {
  get(k) { try { return localStorage.getItem(k); } catch (e) { return null; } },
  set(k, v) { try { localStorage.setItem(k, v); } catch (e) { /* private mode / blocked */ } },
  del(k) { try { localStorage.removeItem(k); } catch (e) { } }
};

const srStatus = $('#srStatus'), srAlert = $('#srAlert');
function announce(msg, assertive = false) {
  const el = assertive ? srAlert : srStatus;
  el.textContent = '';
  requestAnimationFrame(() => { el.textContent = msg; });
}

// apply theme colours from CONFIG
(function applyTheme() {
  const c = CONFIG.colors, s = document.documentElement.style;
  s.setProperty('--sky-top', c.skyTop); s.setProperty('--sky-mid', c.skyMid); s.setProperty('--sky-bottom', c.skyBottom);
  s.setProperty('--hill-back', c.hillBack); s.setProperty('--hill-front', c.hillFront);
  s.setProperty('--accent', c.accent); s.setProperty('--accent-dark', c.accentDark);
  s.setProperty('--danger', c.danger); s.setProperty('--warning', c.warning); s.setProperty('--success', c.success);
})();

3.3 The mock back end

script.js: mock backend
/* =====================================================================
   MOCK BACKEND
   Replace the bodies of these functions with real calls to your Laravel API.
   ===================================================================== */
const api = {
  /**
   * Real Laravel (Sanctum SPA) version:
   *   await fetch('/sanctum/csrf-cookie', { credentials: 'include' });       // sets XSRF-TOKEN cookie
   *   const res = await fetch('/api/login', {
   *     method: 'POST', credentials: 'include',
   *     headers: { 'Content-Type': 'application/json', 'Accept': 'application/json',
   *                'X-XSRF-TOKEN': decodeURIComponent(getCookie('XSRF-TOKEN')) },
   *                // or 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content
   *     body: JSON.stringify({ email, password, remember })
   *   });
   *   if (res.status === 429) return { ok: false, locked: true, retryAfter: +res.headers.get('Retry-After') || 60 };
   *   if (res.status === 422 || res.status === 401) return { ok: false, error: 'invalid' };
   *   const data = await res.json();             // e.g. { two_factor: true }
   *   return { ok: true, otpRequired: !!data.two_factor };
   *
   * Server side: never rely on this page's counter. Throttle in Laravel, e.g.
   *   Route::post('/login', ...)->middleware('throttle:login');
   *   RateLimiter::for('login', fn (Request $r) =>
   *       Limit::perMinute(3)->by(Str::lower($r->input('email')).'|'.$r->ip()));
   */
  async login(email, password, remember) {
    await sleep(CONFIG.timings.fakeLatency);
    if (email.toLowerCase() === CONFIG.demo.email && password === CONFIG.demo.password) {
      return { ok: true, otpRequired: true };
    }
    return { ok: false, error: 'invalid' };
  },

  /**
   * Real version: POST /api/verify-otp  { code }  (same CSRF headers as above)
   *   200 → { redirect: '/dashboard' }   422 → invalid code   429 → too many tries
   */
  async verifyOtp(code) {
    await sleep(CONFIG.timings.fakeLatency * 0.8);
    return code === CONFIG.demo.otp ? { ok: true, redirect: CONFIG.redirectUrl } : { ok: false };
  },

  /** Real version: POST /api/resend-otp (throttle this route on the server too) */
  async resendOtp() { await sleep(500); return { ok: true }; }
};

The api object has three async functions: login, verifyOtp and resendOtp. They wait a little (to feel like a network request) and return simple results like { ok: true, otpRequired: true }. The rest of the page only knows about these results, so connecting a real server means changing only these three functions.

The comments show the real version for a Laravel Sanctum app: fetch the CSRF cookie, POST JSON to /api/login, and map HTTP status codes to results. 429 means locked out, and 422 or 401 means wrong details. Most importantly, they show server-side rate limiting with Laravel's RateLimiter.

3.4 The state machine

script.js: state machine
/* =====================================================================
   STATE MACHINE  idle → typing → submitting → error | locked | otp → success
   ===================================================================== */
const TRANSITIONS = {
  idle:       ['typing', 'submitting', 'locked', 'error'],
  typing:     ['idle', 'submitting', 'locked', 'error'],
  submitting: ['error', 'locked', 'otp'],
  error:      ['typing', 'idle', 'submitting', 'locked'],
  locked:     ['idle'],
  otp:        ['success', 'idle'],
  success:    []
};
let state = 'idle';
function setState(next) {
  if (next === state) return true;
  if (!TRANSITIONS[state].includes(next)) { console.warn(`[login] blocked transition ${state} → ${next}`); return false; }
  state = next;
  document.body.dataset.state = next;
  return true;
}

A state machine is a list of states and the moves allowed between them. Here, TRANSITIONS says, for example, that from submitting you can go to error, locked or otp, but never straight to success. setState() refuses anything not on the list and logs a warning. This prevents a whole class of bugs, such as a double click starting two logins at once.

The current state is also written to body[data-state], which the CSS uses for the stormy card and the lockout panel.

3.5 The wind canvas and grass

script.js: wind canvas and grass
/* =====================================================================
   WIND CANVAS – drifting leaves, wind streaks and storm rain
   ===================================================================== */
const Wind = (() => {
  const cv = $('#windCanvas'), ctx = cv.getContext('2d');
  let W = 0, H = 0, dpr = 1, last = 0, running = false;
  const st = { wind: CONFIG.wind.base, target: CONFIG.wind.base, rain: 0, rainTarget: 0 };
  const leaves = [], streaks = [], drops = [];

  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    W = innerWidth; H = innerHeight;
    cv.width = W * dpr; cv.height = H * dpr;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    if (!running) draw(0);
  }
  function leaf(initial) {
    return {
      x: initial ? rand(0, W) : rand(-120, -20), y: rand(-40, H * 0.9),
      s: rand(.55, 1.25), r: rand(0, 6.28), vr: rand(-2, 2), f: rand(0, 6.28), vf: rand(1.5, 3.5),
      vy: rand(-8, 22), ph: rand(0, 6.28), c: pick(CONFIG.colors.leaves)
    };
  }
  function drawLeaf(l) {
    ctx.save();
    ctx.translate(l.x, l.y); ctx.rotate(l.r); ctx.scale(l.s, l.s * Math.max(.15, Math.abs(Math.cos(l.f))));
    ctx.fillStyle = l.c;
    ctx.beginPath(); ctx.moveTo(-13, 0); ctx.quadraticCurveTo(0, -9, 13, 0); ctx.quadraticCurveTo(0, 9, -13, 0); ctx.fill();
    ctx.strokeStyle = 'rgba(0,0,0,.15)'; ctx.lineWidth = 1;
    ctx.beginPath(); ctx.moveTo(-11, 0); ctx.lineTo(15, 0); ctx.stroke();
    ctx.restore();
  }
  function draw(dt) {
    ctx.clearRect(0, 0, W, H);
    st.wind += (st.target - st.wind) * Math.min(1, dt * 2.2);
    st.rain += (st.rainTarget - st.rain) * Math.min(1, dt * 1.5);
    const w = st.wind, t = performance.now() / 1000;

    // streaks
    if (Math.random() < dt * (0.8 + w * 1.4)) streaks.push({ x: rand(-200, W * .6), y: rand(20, H * .85), len: rand(80, 220), life: 1, sp: rand(300, 520) });
    ctx.lineCap = 'round';
    for (let i = streaks.length - 1; i >= 0; i--) {
      const s = streaks[i];
      s.x += s.sp * (0.4 + w * .45) * dt; s.life -= dt * .7;
      if (s.life <= 0) { streaks.splice(i, 1); continue; }
      const a = Math.sin(s.life * Math.PI) * (.35 + st.rain * .2);
      ctx.strokeStyle = `rgba(255,255,255,${a})`; ctx.lineWidth = 2;
      ctx.beginPath(); ctx.moveTo(s.x, s.y);
      ctx.bezierCurveTo(s.x + s.len * .35, s.y - 12, s.x + s.len * .65, s.y + 12, s.x + s.len, s.y - 4);
      ctx.stroke();
    }
    // leaves
    for (const l of leaves) {
      l.x += (35 + 85 * w) * l.s * dt;
      l.y += (l.vy + Math.sin(t * 1.6 + l.ph) * 28 * Math.min(w, 2.5)) * dt;
      l.r += l.vr * dt * (0.6 + w * .5); l.f += l.vf * dt * (0.6 + w * .4);
      if (l.x > W + 60 || l.y > H + 40 || l.y < -80) Object.assign(l, leaf(false));
      drawLeaf(l);
    }
    // rain
    if (st.rain > 0.02) {
      const want = Math.floor(260 * st.rain);
      while (drops.length < want) drops.push({ x: rand(-100, W), y: rand(-H, 0), v: rand(700, 1000), l: rand(12, 24) });
      ctx.strokeStyle = `rgba(200,220,255,${.55 * st.rain})`; ctx.lineWidth = 1.4;
      ctx.beginPath();
      for (const d of drops) {
        d.y += d.v * dt; d.x += w * 60 * dt;
        if (d.y > H) { d.y = rand(-60, 0); d.x = rand(-100, W); }
        ctx.moveTo(d.x, d.y); ctx.lineTo(d.x + w * 3, d.y + d.l);
      }
      ctx.stroke();
    } else drops.length = 0;
  }
  function loop(now) {
    const dt = Math.min((now - last) / 1000, .05); last = now;
    draw(dt);
    if (running) requestAnimationFrame(loop);
  }
  function start() {
    if (RM()) { running = false; draw(0); return; }
    if (running) return;
    running = true; last = performance.now(); requestAnimationFrame(loop);
  }
  function init() {
    resize();
    for (let i = 0; i < CONFIG.wind.leafCount; i++) leaves.push(leaf(true));
    addEventListener('resize', resize);
    reduceMotion.addEventListener?.('change', () => { running = false; start(); });
    start();
  }
  return {
    init,
    set(v) { st.target = v; },
    gust(power = CONFIG.wind.gust, ms = 900) { const back = st.target; st.target = power; setTimeout(() => { if (st.target === power) st.target = back; }, ms); },
    rain(v) { st.rainTarget = v; if (RM()) { st.rain = v; draw(0); } },
    get base() { return st.target; }
  };
})();

/* grass blades */
(function grass() {
  const g = $('#grass'), n = Math.min(70, Math.floor(innerWidth / 18));
  for (let i = 0; i < n; i++) {
    const b = document.createElement('span');
    b.className = 'blade';
    b.style.height = rand(24, 62) + 'px';
    b.style.animationDuration = rand(1.4, 2.6) + 's';
    b.style.animationDelay = -rand(0, 2) + 's';
    g.appendChild(b);
  }
})();

Wind is a module, a function that runs once and returns only the controls other code needs: set, gust and rain. Inside:

The canvas matches the screen's pixel density (capped at 2) so it stays sharp. With reduced motion, it draws one still frame. The grass is up to 70 <span> blades, each with a random height and sway speed.

3.6 The bird

script.js: bird
/* =====================================================================
   MASCOT – look at caret, cover eyes, peek, react
   ===================================================================== */
const Bird = (() => {
  const el = $('#bird'), body = $('#birdBody');
  const wingL = $('#wingL'), wingR = $('#wingR'), pupils = [$('#pupilL'), $('#pupilR')];
  let mode = 'idle', idleTimer = null, shiver = null;
  const POSE = {
    rest:  { l: 18,   r: -18 },
    cover: { l: -128, r: 128 },
    peek:  { l: -128, r: 62 }
  };
  function wings(p, d = .5) {
    gsap.to(wingL, { rotation: p.l, svgOrigin: '36 80', duration: d, ease: 'back.out(1.6)' });
    gsap.to(wingR, { rotation: p.r, svgOrigin: '124 80', duration: d, ease: 'back.out(1.6)' });
  }
  function look(x, y, d = .25) { gsap.to(pupils, { x, y, duration: d, ease: 'power2.out' }); }
  function tilt(deg) { gsap.to(body, { rotation: deg, svgOrigin: '80 136', duration: .4, ease: 'power2.out' }); }
  function wander() {
    clearTimeout(idleTimer);
    if (mode !== 'idle') return;
    look(rand(-4, 4), rand(-3, 2), .5);
    idleTimer = setTimeout(wander, rand(1600, 3200));
  }
  function set(next) {
    if (mode === 'locked' && next !== 'unlock') return;
    mode = next === 'unlock' ? 'idle' : next;
    clearTimeout(idleTimer);
    if (shiver && next === 'unlock') { shiver.kill(); shiver = null; gsap.to(el, { x: 0, duration: .2 }); }
    switch (mode) {
      case 'idle':   wings(POSE.rest); tilt(0); wander(); break;
      case 'look':   wings(POSE.rest); break;
      case 'cover':  wings(POSE.cover, .45); look(0, 2); tilt(0); break;
      case 'peek':   wings(POSE.peek, .45); look(3, 3); break;
      case 'locked':
        wings(POSE.cover, .4); tilt(0);
        if (!RM()) shiver = gsap.to(el, { x: 2.5, duration: .06, repeat: -1, yoyo: true, ease: 'none' });
        break;
    }
  }
  // follow the caret: ratio 0..1 across the field
  function follow(ratio) {
    if (mode !== 'look') return;
    const x = -5 + clamp(ratio, 0, 1) * 10;
    look(x, 3.5, .18);
    tilt((ratio - .5) * 10);
  }
  function ruffle() {
    if (RM()) return;
    gsap.fromTo(el, { scaleX: 1.12, scaleY: .9 }, { scaleX: 1, scaleY: 1, duration: .6, ease: 'elastic.out(1.2, .35)' });
    const r = el.getBoundingClientRect();
    for (let i = 0; i < 6; i++) {
      const f = document.createElement('span'); f.className = 'feather'; document.body.appendChild(f);
      gsap.fromTo(f, { x: r.left + r.width / 2 + rand(-30, 30), y: r.top + r.height * .6, rotation: rand(0, 180), opacity: 1 },
        { x: `+=${rand(40, 140)}`, y: `+=${rand(60, 160)}`, rotation: `+=${rand(180, 540)}`, opacity: 0, duration: rand(1.2, 1.8), ease: 'power1.in', onComplete: () => f.remove() });
    }
  }
  function hop() {
    if (RM()) return;
    gsap.fromTo(el, { y: 0 }, { y: -14, duration: .18, yoyo: true, repeat: 3, ease: 'power1.out' });
  }
  function flyAway() {
    clearTimeout(idleTimer); mode = 'gone';
    const tl = gsap.timeline();
    tl.to(wingL, { rotation: -70, svgOrigin: '36 80', duration: .09, repeat: 11, yoyo: true, ease: 'sine.inOut' }, 0)
      .to(wingR, { rotation: 70, svgOrigin: '124 80', duration: .09, repeat: 11, yoyo: true, ease: 'sine.inOut' }, 0)
      .to(el, { y: -40, duration: .35, ease: 'power2.out' }, 0)
      .to(el, { x: innerWidth * .6, y: -innerHeight * .8, rotation: 18, scale: .5, duration: 1.2, ease: 'power2.in' }, .35);
    return tl;
  }
  return { set, follow, look, ruffle, hop, flyAway, get mode() { return mode; } };
})();
The bluebird lowering one wing to peek while the password Demo@123 is visible and the windsock meter shows Hurricane
Peek: one wing lowers when the password is shown.

The bird has named poses stored as wing angles in POSE: rest, cover and peek. wings() animates both wings with GSAP. svgOrigin sets the rotation point in SVG coordinates (the shoulders), and back.out easing gives a little overshoot.

3.7 Letters that fly like leaves

script.js: flying letters
/* =====================================================================
   FLYING LETTERS – characters arrive and leave as leaves on the wind
   ===================================================================== */
const measureCtx = document.createElement('canvas').getContext('2d');
function caretPoint(input) {
  const cs = getComputedStyle(input);
  measureCtx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
  const caret = input.selectionStart ?? input.value.length;
  const shown = input.type === 'password' ? '•'.repeat(caret) : input.value.slice(0, caret);
  const r = input.getBoundingClientRect();
  const x = r.left + parseFloat(cs.paddingLeft) + measureCtx.measureText(shown).width - input.scrollLeft;
  const maxX = r.right - parseFloat(cs.paddingRight);
  return { x: Math.min(x, maxX), y: r.top + r.height / 2, ratio: (Math.min(x, maxX) - r.left) / r.width };
}
function makeFlyer(ch) {
  const f = document.createElement('span');
  f.className = 'flyer';
  f.textContent = ch;
  f.style.setProperty('--leaf', pick(CONFIG.colors.leaves));
  document.body.appendChild(f);
  return f;
}
function flyIn(tx, ty, ch) {
  if (RM()) return;
  const f = makeFlyer(ch);
  const sx = tx - rand(160, 280), sy = ty + rand(-150, 120);
  const d = CONFIG.timings.letterFlight;
  gsap.timeline({ onComplete: () => f.remove() })
    .set(f, { x: sx - 14, y: sy - 14, rotation: rand(-200, 200), scale: rand(.8, 1.15), opacity: 0 })
    .to(f, { opacity: 1, duration: .12 })
    .to(f, { x: (sx + tx) / 2 - 14 + rand(-30, 30), y: Math.min(sy, ty) - rand(30, 70) - 14, rotation: `+=${rand(160, 320)}`, duration: d * .5, ease: 'sine.out' }, 0)
    .to(f, { x: tx - 14, y: ty - 14, rotation: 0, scale: .62, duration: d * .5, ease: 'power2.inOut' })
    .to(f, { opacity: 0, scale: .2, duration: .18 });
}
function blowAway(x, y, ch, strength = 1) {
  if (RM()) return;
  const f = makeFlyer(ch);
  gsap.timeline({ onComplete: () => f.remove() })
    .set(f, { x: x - 14, y: y - 14, scale: .7, opacity: 1 })
    .to(f, { x: `+=${rand(160, 380) * strength}`, y: `-=${rand(40, 200) * strength}`, rotation: rand(240, 900), scale: rand(.8, 1.2), duration: rand(.9, 1.4), ease: 'power1.in' })
    .to(f, { opacity: 0, duration: .4 }, '-=.4');
}
// letters of a whole field scatter (wrong password)
function scatterField(input) {
  const r = input.getBoundingClientRect();
  const n = Math.min(input.value.length, 16);
  const cs = getComputedStyle(input);
  measureCtx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
  const charW = measureCtx.measureText('•').width;
  for (let i = 0; i < n; i++) {
    const ch = input.type === 'password' ? '•' : input.value[i];
    setTimeout(() => blowAway(r.left + parseFloat(cs.paddingLeft) + charW * (i + .5), r.top + r.height / 2, ch, 1.3), i * 35);
  }
}
function attachLetterWind(input, onCaret) {
  let prev = input.value;
  input.addEventListener('input', () => {
    const val = input.value;
    if (val.length > prev.length) {
      const added = val.length - prev.length;
      const p = caretPoint(input);
      const end = input.selectionStart ?? val.length;
      const chars = val.slice(end - added, end).slice(-12);
      [...chars].forEach((c, i) => setTimeout(() => flyIn(p.x - (chars.length - 1 - i) * 7, p.y, input.type === 'password' ? '' : c), i * 45));
    } else if (val.length < prev.length) {
      const removed = prev.length - val.length;
      const at = input.selectionStart ?? val.length;
      const gone = prev.substr(at, removed).slice(0, 10);
      const p = caretPoint(input);
      [...gone].forEach((c, i) => setTimeout(() => blowAway(p.x + i * 8, p.y, input.type === 'password' ? '' : c, .7), i * 40));
    }
    prev = val;
    onCaret && onCaret(caretPoint(input));
  });
  const sync = () => { prev = input.value; };
  input.addEventListener('change', sync);
  return { reset: sync };
}

To fly a letter to the right spot, we need to know where the text cursor (caret) is. Browsers don't tell you that for inputs, so caretPoint() works it out: it copies the input's font onto a hidden canvas and uses measureText() to measure the text before the caret. For passwords it measures bullets (•) instead, because that's what's on screen.

flyIn() starts a leaf off to the left and arcs it through a random midpoint to the caret, spinning as it goes, where it shrinks and fades as the real character appears. blowAway() does the reverse for deleted characters. attachLetterWind() compares the old and new value on every input to know what was typed or deleted. Password letters fly as blank leaves, so nothing secret is ever shown.

3.8 Email, password, Caps Lock and remember me

script.js: form elements
/* =====================================================================
   FORM ELEMENTS
   ===================================================================== */
const form = $('#loginForm'), fields = $('#loginFields');
const email = $('#email'), pw = $('#password'), toggle = $('#togglePw');
const emailHint = $('#emailHint'), emailStatus = $('#emailStatus'), pwHint = $('#pwHint');
const loginBtn = $('#loginBtn'), capsWarn = $('#capsWarn');
const card = $('#card'), cardWrap = $('#cardWrap');

function goTyping() { if (state === 'idle' || state === 'error') setState('typing'); }

/* ---- email: caret following + debounced validation ---- */
const emailWind = attachLetterWind(email, p => Bird.follow(p.ratio));
email.addEventListener('focus', () => { goTyping(); Bird.set('look'); Bird.follow(caretPoint(email).ratio); });
['click', 'keyup', 'select'].forEach(ev => email.addEventListener(ev, () => Bird.follow(caretPoint(email).ratio)));
email.addEventListener('blur', () => { setTimeout(maybeIdle, 0); validateEmail(true); });

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
let emailTimer = null;
email.addEventListener('input', () => {
  goTyping();
  clearTimeout(emailTimer);
  emailStatus.className = 'status';
  email.classList.remove('valid');
  email.removeAttribute('aria-invalid');
  emailHint.className = 'hint'; emailHint.textContent = 'Checking…';
  emailTimer = setTimeout(() => validateEmail(false), CONFIG.timings.emailDebounce);
});
function validateEmail(final) {
  clearTimeout(emailTimer);
  const v = email.value.trim();
  if (!v) {
    emailStatus.className = 'status'; email.classList.remove('valid'); email.removeAttribute('aria-invalid');
    emailHint.className = 'hint'; emailHint.textContent = final ? '' : "We'll never share your email.";
    return false;
  }
  const ok = EMAIL_RE.test(v);
  emailStatus.className = 'status ' + (ok ? 'ok' : 'bad');
  email.classList.toggle('valid', ok);
  if (ok) { email.removeAttribute('aria-invalid'); emailHint.className = 'hint good'; emailHint.textContent = 'Looks good, the wind knows this address.'; }
  else { email.setAttribute('aria-invalid', 'true'); emailHint.className = 'hint error'; emailHint.textContent = 'That email looks a little windswept. Please check it.'; }
  return ok;
}

/* ---- password: cover eyes, peek, strength, caps lock ---- */
const pwWind = attachLetterWind(pw);
pw.addEventListener('focus', () => { goTyping(); Bird.set(pw.type === 'text' ? 'peek' : 'cover'); });
pw.addEventListener('blur', e => { if (e.relatedTarget === toggle) return; setTimeout(maybeIdle, 0); });
toggle.addEventListener('mousedown', e => e.preventDefault());
toggle.addEventListener('click', () => {
  const show = pw.type === 'password';
  pw.type = show ? 'text' : 'password';
  toggle.setAttribute('aria-pressed', String(show));
  toggle.setAttribute('aria-label', show ? 'Hide password' : 'Show password');
  announce(show ? 'Password shown' : 'Password hidden');
  pw.focus();
  Bird.set(show ? 'peek' : 'cover');
  Wind.gust(2, 500);
});

const STAGES = ['Breeze', 'Gust', 'Gale', 'Hurricane'];
const bars = $$('.bar i'), sock = $('#sock'), meterLabel = $('#meterLabel');
function scorePassword(v) {
  if (!v) return 0;
  let s = 0;
  if (v.length >= 8) s++;
  if (/[a-z]/.test(v) && /[A-Z]/.test(v)) s++;
  if (/\d/.test(v)) s++;
  if (/[^A-Za-z0-9]/.test(v)) s++;
  if (v.length >= 12) s++;
  return clamp(Math.ceil(s * 4 / 5), 1, 4);       // 1..4
}
let lastStage = 0;
pw.addEventListener('input', () => {
  goTyping();
  const stage = scorePassword(pw.value);
  bars.forEach((b, i) => {
    b.style.transform = `scaleX(${i < stage ? 1 : 0})`;
    b.style.backgroundColor = CONFIG.colors.strength[Math.max(stage - 1, 0)];
  });
  sock.style.transform = `scaleX(${stage ? .25 + stage * .1875 : .12})`;
  sock.style.setProperty('--flutter', stage ? (1.4 - stage * .28) + 's' : '1.4s');
  meterLabel.textContent = 'Wind strength: ' + (stage ? STAGES[stage - 1] : 'calm');
  if (stage !== lastStage && stage) announce(`Password strength ${STAGES[stage - 1]}, ${stage} of 4`);
  if (stage === 4 && lastStage < 4) Wind.gust(2.2, 700);
  lastStage = stage;
  pwHint.className = 'hint'; pwHint.textContent = '';
  pw.removeAttribute('aria-invalid');
});

function capsCheck(e) {
  if (!e.getModifierState) return;
  const on = e.getModifierState('CapsLock');
  if (on !== capsWarn.classList.contains('on')) {
    capsWarn.classList.toggle('on', on);
    if (on) announce('Caps Lock is on');
  }
}
['keydown', 'keyup'].forEach(ev => { pw.addEventListener(ev, capsCheck); email.addEventListener(ev, capsCheck); });
pw.addEventListener('blur', () => capsWarn.classList.remove('on'));

function maybeIdle() {
  const a = document.activeElement;
  if (a === email || a === pw || a === toggle) return;
  if (state === 'typing') setState('idle');
  if (Bird.mode !== 'locked') Bird.set('idle');
}

/* ---- remember me ---- */
const remember = $('#remember');
const savedEmail = store.get(CONFIG.storageKeys.email);
if (savedEmail) { email.value = savedEmail; remember.checked = true; emailWind.reset(); validateEmail(false); }

3.9 Attempts

script.js: attempts
/* =====================================================================
   ATTEMPTS (3 leaves)
   ===================================================================== */
const attemptIcons = $('#attemptIcons'), attemptsText = $('#attemptsText');
let failures = clamp(parseInt(store.get(CONFIG.storageKeys.failures) || '0', 10) || 0, 0, CONFIG.maxAttempts);
const LEAF_ICON = c => `<svg viewBox="0 0 24 24"><path d="M4 20 C4 9 11 4 21 3 C21 13 15 20 4 20 Z" fill="${c}"/><path d="M4 20 L15 9" stroke="rgba(0,0,0,.25)" stroke-width="1.5" stroke-linecap="round"/></svg>`;
function renderAttempts(animateIn = false) {
  attemptIcons.innerHTML = '';
  for (let i = 0; i < CONFIG.maxAttempts; i++) {
    attemptIcons.insertAdjacentHTML('beforeend', LEAF_ICON(['#6cc07a', '#a7cf5b', '#8bd497'][i % 3]));
  }
  const icons = $$('svg', attemptIcons);
  icons.forEach((s, i) => { if (i >= CONFIG.maxAttempts - failures) s.classList.add('gone'); });
  if (animateIn) gsap.fromTo(icons, { scale: 0, rotation: -90, opacity: 0 }, { scale: 1, rotation: 0, opacity: 1, duration: .6, stagger: .15, ease: 'back.out(2)' });
  attemptsText.textContent = attemptsLabel(CONFIG.maxAttempts - failures);
}
function attemptsLabel(n) {
  n = Math.max(n, 0);
  return `${n} ${n === 1 ? 'attempt' : 'attempts'} remaining`;
}
function loseAttempt() {
  const icons = $$('svg:not(.gone)', attemptIcons);
  const leaf = icons[icons.length - 1];
  if (!leaf) return;
  if (RM()) { leaf.classList.add('gone'); return; }
  gsap.to(leaf, { x: rand(120, 200), y: -rand(60, 120), rotation: 540, opacity: 0, duration: 1.1, ease: 'power1.in',
    onComplete: () => { leaf.classList.add('gone'); gsap.set(leaf, { clearProps: 'all' }); leaf.classList.add('gone'); } });
}
renderAttempts();

Each remaining attempt is a leaf icon. A failed login blows the last one away with a spin. The count is saved, so refreshing the page doesn't give you fresh attempts.

3.10 Submitting

script.js: submit
/* =====================================================================
   SUBMIT
   ===================================================================== */
function setBusy(btn, busy, text) {
  btn.setAttribute('aria-busy', String(busy));
  btn.disabled = busy;
  if (text) $('.label', btn).textContent = text;
}
function shake(el) {
  if (RM()) return;
  gsap.fromTo(el, { x: 0 }, { keyframes: { x: [-12, 11, -8, 6, -3, 0] }, duration: .5, ease: 'power1.out' });
}

form.addEventListener('submit', async e => {
  e.preventDefault();
  if (state === 'submitting' || state === 'locked') return;

  // client-side checks (do not count as attempts)
  const okEmail = validateEmail(true);
  if (!okEmail || !pw.value) {
    if (!email.value.trim()) { email.setAttribute('aria-invalid', 'true'); emailHint.className = 'hint error'; emailHint.textContent = 'Please enter your email.'; }
    if (!pw.value) { pw.setAttribute('aria-invalid', 'true'); pwHint.className = 'hint error'; pwHint.textContent = 'Please enter your password.'; }
    setState('error');
    shake(card); Wind.gust(2.4, 600);
    announce(!okEmail ? 'Please enter a valid email address.' : 'Please enter your password.', true);
    (!okEmail ? email : pw).focus();
    return;
  }

  setState('submitting');
  setBusy(loginBtn, true, 'Catching the wind…');
  Wind.set(1.8);
  announce('Signing in, please wait.');
  let res;
  try { res = await api.login(email.value.trim(), pw.value, remember.checked); }
  catch (err) { res = { ok: false, error: 'network' }; }
  setBusy(loginBtn, false, 'Log in');
  Wind.set(CONFIG.wind.base);

  if (res.ok) {
    if (remember.checked) store.set(CONFIG.storageKeys.email, email.value.trim()); else store.del(CONFIG.storageKeys.email);
    failures = 0; store.del(CONFIG.storageKeys.failures);
    setState('otp');
    showOtp();
    return;
  }
  if (res.locked) { startLockout(res.retryAfter || CONFIG.lockoutSeconds); return; }
  if (res.error === 'network') {
    setState('error');
    announce('Could not reach the server. Please try again.', true);
    pwHint.className = 'hint error'; pwHint.textContent = 'Connection lost in the wind. Try again.';
    return;
  }
  wrongPassword();
});

function wrongPassword() {
  failures++;
  store.set(CONFIG.storageKeys.failures, String(failures));
  const left = CONFIG.maxAttempts - failures;

  // themed error: letters scatter, card shakes, bird ruffles, a strong gust
  scatterField(pw);
  Wind.gust(CONFIG.wind.gust, 1100);
  shake(card);
  Bird.ruffle();
  loseAttempt();
  attemptsText.textContent = attemptsLabel(left);

  if (left <= 0) {
    setTimeout(() => { pw.value = ''; pwWind.reset(); pw.dispatchEvent(new Event('input')); }, 120);
    setTimeout(() => startLockout(CONFIG.lockoutSeconds), 700);
    return;
  }
  announce(`Wrong email or password. ${attemptsLabel(left)}.`, true);
  pw.focus();
  // Clear the field once the letters have blown away. Clearing fires 'input', which resets the
  // meter AND the hint, so the error message is written after it, not before.
  setTimeout(() => {
    pw.value = ''; pwWind.reset(); pw.dispatchEvent(new Event('input'));
    setState('error');
    pw.setAttribute('aria-invalid', 'true');
    pwHint.className = 'hint error';
    pwHint.textContent = `Wrong email or password. ${left} ${left === 1 ? 'attempt' : 'attempts'} left.`;
  }, 120);
}
After a wrong password the field is red with the message Wrong email or password, 1 attempt left, and only one leaf remains
A wrong password: letters scatter, a leaf blows away and the hint explains.

The submit handler first checks the fields in the browser. Those checks don't count as attempts. Then it moves to submitting:

It awaits api.login(), wrapped in try/catch so a network failure shows a friendly message instead of breaking the page.

wrongPassword() is the themed error:

The field is cleared after the letters have flown. Clearing it fires an input event, which resets the meter and the hint, so the error message is written after that, or it would vanish straight away. On the last attempt, the storm starts.

3.11 The storm lockout

script.js: lockout
/* =====================================================================
   LOCKOUT – storm
   ===================================================================== */
const storm = $('#storm'), stormClouds = $('#stormClouds'), flash = $('#flash');
const lockClock = $('#lockClock'), lockBar = $('#lockBar');
let lockTimer = null, lightningTimer = null;

function startLockout(seconds) {
  const until = Date.now() + seconds * 1000;
  store.set(CONFIG.storageKeys.lockUntil, String(until));
  enterLock(until, seconds);
}
function enterLock(until, total) {
  if (!setState('locked')) { state = 'locked'; document.body.dataset.state = 'locked'; }
  fields.disabled = true;
  document.activeElement && document.activeElement.blur();
  Bird.set('locked');
  pwHint.textContent = ''; pwHint.className = 'hint';

  // storm rolls in
  Wind.set(CONFIG.wind.storm); Wind.rain(1);
  gsap.to(storm, { opacity: 1, duration: 1.2, ease: 'power2.out' });
  gsap.to(stormClouds, { y: 0, opacity: 1, duration: 1.4, ease: 'power3.out' });
  if (!RM()) {
    shake(card);
    const strike = () => {
      gsap.timeline()
        .to(flash, { opacity: .85, duration: .05 }).to(flash, { opacity: 0, duration: .12 })
        .to(flash, { opacity: .6, duration: .05 }).to(flash, { opacity: 0, duration: .4 });
      lightningTimer = setTimeout(strike, rand(3500, 7000));
    };
    lightningTimer = setTimeout(strike, 400);
  }

  const totalMs = (total || CONFIG.lockoutSeconds) * 1000;
  const tick = () => {
    const ms = Math.max(0, until - Date.now());
    const s = Math.ceil(ms / 1000);
    lockClock.textContent = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
    lockBar.style.transform = `scaleX(${clamp(ms / totalMs, 0, 1)})`;
    if (s === 30 || s === 10) announce(`${s} seconds until you can try again.`);
    if (ms <= 0) { clearInterval(lockTimer); unlock(); }
  };
  tick();
  lockTimer = setInterval(tick, 250);
  announce(`Too many failed attempts. Login is locked for ${Math.ceil((until - Date.now()) / 1000)} seconds.`, true);
}
function unlock() {
  clearTimeout(lightningTimer);
  store.del(CONFIG.storageKeys.lockUntil);
  failures = 0; store.del(CONFIG.storageKeys.failures);

  // storm clears, sun breaks through
  Wind.rain(0); Wind.set(CONFIG.wind.base);
  gsap.to(stormClouds, { y: '-100%', opacity: 0, duration: 1.4, ease: 'power2.in' });
  gsap.to(storm, { opacity: 0, duration: 1.6, ease: 'power2.inOut' });
  gsap.timeline().to(flash, { opacity: .35, duration: .5 }).to(flash, { opacity: 0, duration: 1.2 });
  Bird.set('unlock'); Bird.hop();

  fields.disabled = false;
  renderAttempts(true);
  setState('idle');
  announce('The storm has passed. You can try logging in again.', true);
  setTimeout(() => email.focus(), 300);
}
The storm lockout: dark sky, storm clouds, rain, the form disabled and a panel counting down from 58 seconds
Three wrong tries: the storm rolls in and the form locks for a minute.

The lockout saves an end time (Date.now() plus 60 seconds) in localStorage, not a countdown. On every tick, the remaining time is worked out from that end time, which keeps it accurate even if the browser slows timers in a background tab. And because it's saved, refreshing the page restores the storm.

During the storm:

unlock() clears the sky with a burst of sunshine, the bird hops, and three fresh leaves spring back.

Important: a lockout in the browser is only a nice experience, not security. Anyone can clear their storage or call your API directly. The real limit must be on the server, as the Laravel RateLimiter comment in the mock back end shows.

3.12 The six-digit code

script.js: OTP step
/* =====================================================================
   OTP STEP
   ===================================================================== */
const otpPanel = $('#otpPanel'), loginPanel = $('#loginPanel'), otpBoxes = $('#otpBoxes');
const otpForm = $('#otpForm'), otpBtn = $('#otpBtn'), otpHint = $('#otpHint');
const resendBtn = $('#resendBtn'), backBtn = $('#backBtn');
let resendTimer = null, verifying = false;

for (let i = 0; i < CONFIG.otpLength; i++) {
  const inp = document.createElement('input');
  inp.type = 'text'; inp.inputMode = 'numeric'; inp.maxLength = 1; inp.pattern = '[0-9]*';
  inp.autocomplete = i === 0 ? 'one-time-code' : 'off';
  inp.setAttribute('aria-label', `Digit ${i + 1} of ${CONFIG.otpLength}`);
  otpBoxes.appendChild(inp);
}
const boxes = $$('input', otpBoxes);
const code = () => boxes.map(b => b.value).join('');

function boxCenter(b) { const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; }
function fillBox(b, d) {
  b.value = d; b.classList.add('filled'); b.removeAttribute('aria-invalid');
  const c = boxCenter(b); flyIn(c.x, c.y, d);
  if (!RM()) gsap.fromTo(b, { scale: .85 }, { scale: 1, duration: .45, delay: CONFIG.timings.letterFlight * .8, ease: 'back.out(3)' });
}
function clearBox(b, blow = true) {
  if (b.value && blow) { const c = boxCenter(b); blowAway(c.x, c.y, b.value, .6); }
  b.value = ''; b.classList.remove('filled');
}
boxes.forEach((b, i) => {
  b.addEventListener('input', () => {
    const d = b.value.replace(/\D/g, '').slice(-1);
    b.value = '';
    if (!d) return;
    fillBox(b, d);
    if (i < boxes.length - 1) boxes[i + 1].focus();
    if (code().length === CONFIG.otpLength) setTimeout(verifyOtp, CONFIG.timings.letterFlight * 1000);
  });
  b.addEventListener('keydown', e => {
    if (e.key === 'Backspace') {
      e.preventDefault();
      if (b.value) clearBox(b);
      else if (i > 0) { boxes[i - 1].focus(); clearBox(boxes[i - 1]); }
    } else if (e.key === 'Delete') { e.preventDefault(); clearBox(b); }
    else if (e.key === 'ArrowLeft' && i > 0) { e.preventDefault(); boxes[i - 1].focus(); }
    else if (e.key === 'ArrowRight' && i < boxes.length - 1) { e.preventDefault(); boxes[i + 1].focus(); }
    else if (e.key === 'Home') { e.preventDefault(); boxes[0].focus(); }
    else if (e.key === 'End') { e.preventDefault(); boxes[boxes.length - 1].focus(); }
  });
  b.addEventListener('focus', () => b.select());
  b.addEventListener('paste', e => {
    e.preventDefault();
    const digits = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '').slice(0, CONFIG.otpLength - i);
    if (!digits) return;
    [...digits].forEach((d, k) => setTimeout(() => fillBox(boxes[i + k], d), k * 70));
    const nextIdx = Math.min(i + digits.length, boxes.length - 1);
    boxes[nextIdx].focus();
    announce(`${digits.length} digits pasted`);
    setTimeout(() => { if (code().length === CONFIG.otpLength) verifyOtp(); }, digits.length * 70 + CONFIG.timings.letterFlight * 1000);
  });
});

function panelSwap(outPanel, inPanel, outSel, inSel, dir = 1) {
  return new Promise(resolve => {
    const outs = $$(outSel, outPanel);
    gsap.timeline({ onComplete: resolve })
      .to(outs, { x: 260 * dir, y: () => rand(-80, -20), rotation: () => rand(20, 70) * dir, opacity: 0, duration: .55, stagger: .05, ease: 'power2.in' })
      .call(() => {
        outPanel.hidden = true; gsap.set(outs, { clearProps: 'transform,opacity' });
        inPanel.hidden = false;
      })
      .fromTo($$(inSel, inPanel), { x: -240 * dir, rotation: -30 * dir, opacity: 0 },
        { x: 0, rotation: 0, opacity: 1, duration: .6, stagger: .06, ease: 'back.out(1.4)' });
  });
}

async function showOtp() {
  Bird.set('idle'); Bird.hop();
  Wind.gust(2.6, 900);
  const masked = email.value.trim().replace(/^(.)(.*)(@.*)$/, (m, a, b, c) => a + '*'.repeat(Math.max(b.length, 2)) + c);
  $('#otpSub').textContent = `We blew a ${CONFIG.otpLength}-digit code to ${masked}.`;
  boxes.forEach(b => clearBox(b, false));
  otpHint.textContent = ''; otpHint.className = 'hint';
  if (RM()) { loginPanel.hidden = true; otpPanel.hidden = false; }
  else await panelSwap(loginPanel, otpPanel, '.blow', '.blow2', 1);
  card.setAttribute('aria-labelledby', 'otpTitle');
  boxes[0].focus();
  announce(`Password accepted. Enter the ${CONFIG.otpLength}-digit code sent to ${masked}.`);
  startResend();
}
function startResend() {
  clearInterval(resendTimer);
  let s = CONFIG.timings.resendSeconds;
  resendBtn.disabled = true;
  const t = () => {
    resendBtn.textContent = s > 0 ? `Resend in 0:${String(s).padStart(2, '0')}` : 'Resend code';
    if (s <= 0) { resendBtn.disabled = false; clearInterval(resendTimer); }
    s--;
  };
  t(); resendTimer = setInterval(t, 1000);
}
resendBtn.addEventListener('click', async () => {
  resendBtn.disabled = true; resendBtn.textContent = 'Sending…';
  await api.resendOtp();
  Wind.gust(2.4, 700);
  announce('A new code is on its way.');
  otpHint.className = 'hint good'; otpHint.textContent = 'New code sent!';
  startResend();
});
backBtn.addEventListener('click', async () => {
  clearInterval(resendTimer);
  setState('idle');
  pw.value = ''; pwWind.reset(); pw.dispatchEvent(new Event('input'));
  if (RM()) { otpPanel.hidden = true; loginPanel.hidden = false; }
  else await panelSwap(otpPanel, loginPanel, '.blow2', '.blow', -1);
  card.setAttribute('aria-labelledby', 'loginTitle');
  email.focus();
});
otpForm.addEventListener('submit', e => { e.preventDefault(); verifyOtp(); });

async function verifyOtp() {
  if (verifying || state !== 'otp') return;
  const c = code();
  if (c.length < CONFIG.otpLength) {
    otpHint.className = 'hint error'; otpHint.textContent = `Enter all ${CONFIG.otpLength} digits.`;
    announce(`Enter all ${CONFIG.otpLength} digits.`, true);
    shake(otpBoxes);
    const first = boxes.find(b => !b.value); first && first.focus();
    return;
  }
  verifying = true;
  setBusy(otpBtn, true, 'Checking…');
  boxes.forEach(b => b.readOnly = true);
  let res;
  try { res = await api.verifyOtp(c); } catch (e) { res = { ok: false } }
  verifying = false;
  boxes.forEach(b => b.readOnly = false);
  setBusy(otpBtn, false, 'Verify');
  if (res.ok) { success(res.redirect || CONFIG.redirectUrl); return; }

  // wrong code: digits blow away
  shake(otpBoxes); Wind.gust(CONFIG.wind.gust, 900); Bird.ruffle();
  boxes.forEach((b, i) => { b.setAttribute('aria-invalid', 'true'); setTimeout(() => clearBox(b), i * 60); });
  otpHint.className = 'hint error'; otpHint.textContent = 'That code blew the wrong way. Try again.';
  announce('Incorrect code. Please try again.', true);
  setTimeout(() => boxes[0].focus(), 400);
}

Code inputs are fiddly to get right. This one handles everything people expect:

Each box has its own label (“Digit 3 of 6”). panelSwap() blows the old panel's pieces away and sweeps the new ones in from the other side. The email is masked (d***@test.com) when shown, a common privacy habit. The resend button counts down 30 seconds before it can be used again.

3.13 Success and start-up

script.js: success
/* =====================================================================
   SUCCESS – everything blows away, the cloud gate opens
   ===================================================================== */
const gate = $('#gate'), welcome = $('#welcome');
async function success(url) {
  setState('success');
  clearInterval(resendTimer);
  announce('Verified. Welcome aboard! Redirecting to your dashboard.', true);
  const canNavigate = !CONFIG.demoMode && /^https?:$/.test(location.protocol);
  const go = () => {
    if (canNavigate) location.assign(url);
    else { $('#welcomeText').textContent = `Demo complete. A real app would now open ${url}`; $('#replayBtn').style.display = 'inline-block'; }
  };
  if (RM()) {
    gsap.set(gate, { autoAlpha: 1 }); gsap.set('.gate .half', { x: 0 }); gsap.set(welcome, { opacity: 1 });
    setTimeout(go, 800); return;
  }

  Wind.set(5.5);
  const parts = $$('.blow2', otpPanel);
  const tl = gsap.timeline();
  tl.add(Bird.flyAway(), 0)
    .to(parts, { x: () => rand(300, 600), y: () => rand(-260, -60), rotation: () => rand(90, 400), opacity: 0, duration: .8, stagger: .07, ease: 'power2.in' }, .25)
    .to(cardWrap, { x: innerWidth * .9, y: -innerHeight * .7, rotation: 160, scale: .25, opacity: 0, duration: 1.2, ease: 'power3.in' }, .7)
    .set(gate, { visibility: 'visible' }, 1.5)
    .to('.gate .half.l', { x: '0%', duration: .9, ease: 'power3.inOut' }, 1.5)
    .to('.gate .half.r', { x: '0%', duration: .9, ease: 'power3.inOut' }, 1.5)
    .to(welcome, { opacity: 1, duration: .5 }, 2.25)
    .fromTo('#welcome h2', { y: 30, scale: .9 }, { y: 0, scale: 1, duration: .7, ease: 'back.out(2)' }, 2.25)
    .call(() => Wind.set(CONFIG.wind.base), [], 2.4)
    // the gate opens like a door of clouds
    .to('.gate .half.l', { x: '-102%', duration: 1.1, ease: 'power3.inOut' }, 3.4)
    .to('.gate .half.r', { x: '102%', duration: 1.1, ease: 'power3.inOut' }, 3.4)
    .to(flash, { opacity: .9, duration: .6 }, 3.6)
    .to(welcome, { opacity: 0, duration: .4 }, 3.9);
  setTimeout(() => {
    if (canNavigate) go();
    else { gsap.to(flash, { opacity: 0, duration: .6 }); gsap.to(welcome, { opacity: 1, duration: .4 }); go(); }
  }, 3400 + CONFIG.timings.redirectDelay - 1800);
}
$('#replayBtn').addEventListener('click', () => location.reload());
script.js: init
/* =====================================================================
   INIT
   ===================================================================== */
Wind.init();
Bird.set('idle');
gsap.set($('#wingL'), { rotation: 18, svgOrigin: '36 80' });
gsap.set($('#wingR'), { rotation: -18, svgOrigin: '124 80' });

// restore an active lockout (survives refresh)
const lockUntil = parseInt(store.get(CONFIG.storageKeys.lockUntil) || '0', 10);
if (lockUntil > Date.now()) {
  failures = CONFIG.maxAttempts;
  renderAttempts();
  enterLock(lockUntil, CONFIG.lockoutSeconds);
} else if (lockUntil) {
  store.del(CONFIG.storageKeys.lockUntil); store.del(CONFIG.storageKeys.failures); failures = 0; renderAttempts();
}

// entrance: the card is blown in
if (!RM()) {
  gsap.from(cardWrap, { x: -120, rotation: -6, opacity: 0, duration: 1, ease: 'back.out(1.3)' });
  gsap.from('#bird', { y: -160, x: -200, rotation: -25, duration: 1.2, delay: .3, ease: 'power3.out' });
}
The success screen: white cloud gate halves closed with Welcome aboard in the centre
Success: the cloud gate closes, then opens onto your dashboard.

The success animation is one GSAP timeline, a sequence where each step has a start time:

  1. 0s: the bird flies away.
  2. 0.25s: the form pieces blow off.
  3. 0.7s: the whole card tumbles away.
  4. 1.5s: the cloud halves close.
  5. 2.25s: “Welcome aboard!” pops up.
  6. 3.4s: the gate opens onto a white flash.

In demo mode the page stays put and offers a replay. Otherwise it navigates to redirectUrl.

At start-up, the code sets the wind and bird, restores any active lockout from storage, and blows the card and bird in from the left.

Connecting a real server

  1. Replace the bodies of api.login, api.verifyOtp and api.resendOtp with fetch() calls. Follow the comments for headers, CSRF and status codes.
  2. Rate-limit login, code verification and resend on the server. Return HTTP 429 with a Retry-After header, and the page will show the storm for that long.
  3. Remove the demo credentials from CONFIG and the demo notes from the HTML.
  4. Set demoMode: false and point redirectUrl, the “Forgot password?” link and “Create an account” at your real pages.
  5. Serve everything over HTTPS.

Test it yourself

  1. Type in the email field. Letters fly in as leaves and the bird follows your cursor. Pause, and a sprout or a wilted leaf appears.
  2. Type a password and watch the windsock grow from Breeze to Hurricane.
  3. Turn on Caps Lock in the password field.
  4. Click the eye. The bird peeks.
  5. Enter a wrong password three times, then refresh during the storm. It's still there.
  6. Sign in with the demo details, paste 123456 into the first box, and watch the gate.
  7. Try the whole thing using only the keyboard, and with a screen reader if you have one.

Share what you built

Changed the meadow to autumn or the bird to a robin? Share a screenshot as a link with sshot.online, or share your code with the free code sharing tool. No account needed.

Conclusion

You've built a login flow that's both delightful and properly engineered:

The playful parts (leaves, windsock, storm and cloud gate) sit on top of that solid base, which is exactly how great interfaces are made.

Frequently asked questions

Does the wind login page need GSAP?

GSAP powers the bird and transitions, loaded from cdnjs. If it fails to load, a small built-in fallback jumps to the end state of each animation, so the login still works.

Is the 3-attempt lockout secure?

Not on its own. The browser lockout is for user experience only. Real protection must be rate limiting on the server, which returns HTTP 429. The page then shows the storm for the time the server specifies.

How do the letters fly into the input?

The script measures the text before the cursor with canvas measureText to find the caret position, then animates a leaf-shaped element from off to the side into that point with GSAP. Password characters fly as blank leaves.

How does the 6-digit code input support paste and SMS autofill?

Each box accepts one digit. A paste handler spreads copied digits across the boxes, and the first box uses autocomplete="one-time-code" with inputmode="numeric", so phones can suggest the code from a text message.

How do I connect it to Laravel or another back end?

Replace the three functions in the api object with fetch calls to your endpoints, send the CSRF token, map status codes like 401, 422 and 429 to the results the page expects, and set demoMode to false.

Related articles

← All guides