Back to the library

Weather — steady rain

Variant: Rain

Ten lanes of rain ending on a drawn sill, because a splash needs a surface and drops that vanish mid-air read as a glitch rather than a landing. Each splash rides the identical duration and negative delay as its own lane, so the lock is structural: it cannot fire when no drop has landed, and no speed remix can drift the pair apart. Depth is three cues kept in agreement — nearer drops are longer, darker and faster — since any one alone is noise and disagreeing they flatten the field. The fall is linear because rain arrives at terminal velocity: it is not entering the scene, the frame is picking it up mid-flight. Reach for it behind an empty state or a waiting view, where the page needs weather rather than progress.

ambientweatherloopstagger

The source

A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Weather — rain</title>
    <style>
      :root {
        /* Neutrals — fixed, never remixed. */
        --ivory: #faf9f5;
        --paper: #ffffff;
        --oat: #e3dacc;
        --slate: #141413;

        /* Knobs — the remix API. A knob exists iff declared here and used below. */
        --hue: 38.8;
        --lift: 0;
        --chroma: 1;
        --round: 1;
        --speed: 1;

        /* Accents — derived from the knobs with the family's offsets baked in,
           so one hue roll moves them together. Defaults reproduce the original hex. */
        --clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
        --clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
        --olive: oklch(
          calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
        );
        --rust: oklch(
          calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
        );

        /* Clock — one fall at the base rate. Each lane multiplies it by its own
           factor between 0.71 and 1.53, none sharing a simple ratio, so the
           landings scatter into a patter instead of a beat. */
        --cycle: 2.8s;
        --t: calc(var(--cycle) / var(--speed));

        --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
        --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
      }

      * {
        box-sizing: border-box;
        margin: 0;
        padding: 0;
      }

      body {
        min-height: 100vh;
        display: grid;
        place-items: center;
        background: var(--ivory);
        font-family:
          system-ui,
          -apple-system,
          'Segoe UI',
          Roboto,
          sans-serif;
        color: var(--slate);
      }

      .panel {
        position: relative;
        overflow: hidden;
        width: min(392px, calc(100% - 26px));
        height: clamp(150px, 46vw, 214px);
        border: 1px solid var(--oat);
        border-radius: calc(16px * var(--round));
        background: var(--paper);
      }

      /* The one fixed mark in the piece. A splash needs a surface to happen on,
         and without the sill drawn the drops would simply vanish mid-air. */
      .sill {
        position: absolute;
        top: 88%;
        right: 0;
        left: 0;
        height: 1px;
        background: var(--oat);
        opacity: 0.9;
      }

      /* Each lane is 88% of the panel tall — top edge to sill — so the same
         translateY percentages land every drop exactly on the line at every
         preview size. The ground is built into the geometry, not timed. */
      .lane {
        position: absolute;
        top: 0;
        left: var(--_x);
        width: 0;
        height: 88%;
        transform: translateY(var(--_y));
        animation: fall calc(var(--t) * var(--_d)) linear infinite;
        /* Negative, so it is already raining on the first frame. An ambient
           background has no opening beat. */
        animation-delay: calc(var(--t) * var(--_lag) * -1);
      }

      /* Linear, because rain arrives at terminal velocity: it is not entering a
         scene, it is already falling when the frame picks it up. An eased drop
         reads as thrown. */
      @keyframes fall {
        0% {
          transform: translateY(-115%);
        }
        76% {
          transform: translateY(0);
          opacity: 1;
        }
        76.6%,
        100% {
          transform: translateY(0);
          /* A cut, not a fade — the drop does not dissolve, it becomes the
             splash. The dead quarter after it is the gap before the next drop. */
          opacity: 0;
        }
      }

      /* Depth is three cues kept in agreement — nearer drops are longer,
         darker and faster. Any one alone is noise; disagreeing they flatten
         the field. */
      .streak {
        position: absolute;
        bottom: 0;
        left: 0;
        width: 1.5px;
        height: calc(17px * var(--_s));
        border-radius: 999px;
        translate: -50% 0;
        opacity: calc(0.28 + var(--_s) * 0.28);
        background: linear-gradient(to top, var(--clay-d), transparent);
      }

      /* The splash rides the same duration and delay as its own lane, so the
         lock is structural: it cannot fire when no drop has landed, and no
         amount of speed remixing can drift the two apart. */
      .splash {
        position: absolute;
        bottom: 0;
        left: 0;
        width: calc(11px * var(--_s));
        height: calc(3.6px * var(--_s));
        border: 1px solid var(--clay-d);
        border-radius: 50%;
        transform: translate(-50%, 50%) scale(0.35);
        opacity: 0;
        animation: splash calc(var(--t) * var(--_d)) linear infinite;
        animation-delay: calc(var(--t) * var(--_lag) * -1);
      }

      @keyframes splash {
        0%,
        75.8% {
          opacity: 0;
          transform: translate(-50%, 50%) scale(0.35);
        }
        /* The pop is near-instant because an impact is; only the ring that
           spreads from it gets a curve, and it decelerates the way spent
           energy does. */
        76.2% {
          opacity: calc(0.3 + var(--_s) * 0.2);
          transform: translate(-50%, 50%) scale(0.5);
          animation-timing-function: var(--ease-out);
        }
        90%,
        100% {
          opacity: 0;
          transform: translate(-50%, 50%) scale(1);
        }
      }

      .copy {
        position: absolute;
        right: clamp(16px, 4.6vw, 24px);
        bottom: clamp(14px, 4.2vw, 22px);
        left: clamp(16px, 4.6vw, 24px);
      }

      .head {
        font-size: clamp(15px, 4.4vw, 19px);
        font-weight: 600;
        letter-spacing: -0.01em;
      }

      .sub {
        margin-top: 3px;
        font-size: clamp(11.5px, 3.3vw, 13px);
        color: color-mix(in srgb, var(--slate) 55%, transparent);
      }

      @media (prefers-reduced-motion: reduce) {
        /* The fall stops and each lane rests at its own --_y — cut the
           animation without those offsets and every drop piles onto the sill.
           The splashes go entirely; what survives is rain as a texture, each
           streak breathing on its own period so the field never blinks as one. */
        .lane {
          animation: none;
        }

        .splash {
          animation: none;
          opacity: 0;
        }

        .streak {
          animation: mist calc(var(--t) * var(--_d) * 2) var(--ease-in-out) infinite alternate;
          animation-delay: calc(var(--t) * var(--_lag) * -2);
        }

        @keyframes mist {
          0% {
            opacity: 0.14;
          }
          100% {
            opacity: 0.5;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <div class="sill" aria-hidden="true"></div>
      <div class="field" aria-hidden="true">
        <span class="lane" style="--_x: 4%; --_y: -18%; --_d: 0.78; --_lag: 0.13; --_s: 1.3">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 13%; --_y: -64%; --_d: 1.39; --_lag: 0.55; --_s: 0.75">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 22%; --_y: -35%; --_d: 0.92; --_lag: 0.82; --_s: 1.1">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 31%; --_y: -80%; --_d: 1.28; --_lag: 0.31; --_s: 0.85">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 42%; --_y: -12%; --_d: 0.71; --_lag: 0.66; --_s: 1.4">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 53%; --_y: -52%; --_d: 1.13; --_lag: 0.05; --_s: 0.95">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 62%; --_y: -71%; --_d: 0.84; --_lag: 0.44; --_s: 1.2">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 73%; --_y: -28%; --_d: 1.53; --_lag: 0.91; --_s: 0.7">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 84%; --_y: -88%; --_d: 1.06; --_lag: 0.24; --_s: 1.05">
          <i class="streak"></i><i class="splash"></i>
        </span>
        <span class="lane" style="--_x: 94%; --_y: -45%; --_d: 1.34; --_lag: 0.72; --_s: 0.8">
          <i class="streak"></i><i class="splash"></i>
        </span>
      </div>
      <div class="copy">
        <p class="head">Steady rain</p>
        <p class="sub">Ambient weather. Nothing here reports state.</p>
      </div>
    </div>
  </body>
</html>

The variant family

Same idea, one axis moved. Compare them side by side before you commit.