Back to the library

Weather — storm flash

Variant: Storm

Eleven seconds carrying just over a second of light: a near strike at 18%, a distant one at 56%, unevenly spaced so the storm never acquires a beat. The near strike is steps(1) throughout — light does not interpolate, and the strobe-gap-strobe is how a discharge restrikes down its own channel — while the distant one gets no steps at all, because miles of cloud diffuse a transient into a swell; distance in this piece is a property of the envelope, not the size. The flash sits behind the ridge, so the hills read as hills only in the instant they become silhouettes. And the rain never reacts — weather has no knowledge of itself, and that indifference is what makes the flash an event in a world rather than an effect on a card. Reach for it on the one surface allowed a moment of drama — a 404, a launch teaser — never beside real work.

ambientweatherloopsvg

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 — storm</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 — eleven seconds carrying just over a second of light: a near
           strike at 18% and a distant one at 56%, unevenly spaced so the storm
           never acquires a beat. The rest is rain. */
        --cycle: 11s;
        --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);
      }

      /* The one panel in the set that is not paper: a flash needs headroom,
         and a white card is already as bright as this piece can go. The dusk
         wash is the value floor the lightning is measured against. */
      .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: color-mix(in srgb, var(--paper) 87%, var(--slate));
      }

      /* Sheet light behind everything, in front of nothing. It sits under the
         ridge on purpose: the flash brightens the sky, and the hills read as
         hills only in the moment they become silhouettes against it. */
      .sky-flash {
        position: absolute;
        inset: 0;
        background: var(--paper);
        opacity: 0;
        animation: sky var(--t) infinite;
      }

      /* The near strike is steps(1) throughout — light does not interpolate,
         and the strobe-gap-strobe is how a real discharge restrikes down the
         same channel. Only the afterglow gets a curve. The distant strike at
         56% gets no steps at all: miles of cloud diffuse a transient into a
         swell, which is how the piece says "far" with brightness alone. */
      @keyframes sky {
        0%,
        17.9% {
          opacity: 0;
          animation-timing-function: steps(1, jump-end);
        }
        18% {
          /* Capped well short of white: the flash has to leave the bolt
             somewhere brighter to exist. */
          opacity: 0.55;
          animation-timing-function: steps(1, jump-end);
        }
        18.5% {
          opacity: 0.12;
          animation-timing-function: steps(1, jump-end);
        }
        18.9% {
          opacity: 0.5;
          animation-timing-function: var(--ease-out);
        }
        23.5%,
        55.4% {
          opacity: 0;
          animation-timing-function: var(--ease-in-out);
        }
        56.6% {
          opacity: 0.24;
          animation-timing-function: var(--ease-in-out);
        }
        60.5%,
        100% {
          opacity: 0;
        }
      }

      .strike {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
      }

      /* The bolt shares the sky's clock and its steps, so channel and light
         can never land a frame apart. It is visible for under 400ms a cycle:
         a bolt you can study is a diagram, not weather. The channel is the
         light source, so its core is paper — brighter than any sky the flash
         reaches — and the clay lives in a wide soft halo behind it, which is
         how a bright line on a bright ground stays legible. */
      .bolt {
        stroke: var(--paper);
        stroke-width: 1.8;
        stroke-linecap: round;
        stroke-linejoin: round;
        fill: none;
        vector-effect: non-scaling-stroke;
        opacity: 0;
        animation: bolt var(--t) infinite;
      }

      .bolt.glow {
        stroke: var(--clay-d);
        stroke-width: 5;
        stroke-opacity: 0.5;
      }

      .bolt.fork {
        stroke-width: 1.1;
      }

      .bolt.fork.glow {
        stroke-width: 3.4;
      }

      @keyframes bolt {
        0%,
        17.9% {
          opacity: 0;
          animation-timing-function: steps(1, jump-end);
        }
        18% {
          opacity: 1;
          animation-timing-function: steps(1, jump-end);
        }
        18.5% {
          opacity: 0.3;
          animation-timing-function: steps(1, jump-end);
        }
        18.9% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        21%,
        100% {
          opacity: 0;
        }
      }

      /* Driven rain: the squall is the rain variant's fall given the wind
         variant's lean. The container tilts once and the lanes stay vertical
         inside it, so every streak shares exactly one slant — rain in wind
         does not disagree with itself about direction. */
      .squall {
        position: absolute;
        inset: -18%;
        transform: rotate(7deg);
      }

      .lane {
        position: absolute;
        top: 0;
        left: var(--_x);
        width: 0;
        height: 100%;
        transform: translateY(var(--_y));
        animation: pour calc(var(--t) * var(--_d)) linear infinite;
        animation-delay: calc(var(--t) * var(--_lag) * -1);
      }

      /* No landing and no splash here — the ground is a silhouette away and
         the rain is texture, not subject. It passes through the frame and
         never reacts to the lightning, because weather has no knowledge of
         itself: the indifference is what makes the flash feel like an event
         happening in a world rather than to it. */
      @keyframes pour {
        from {
          transform: translateY(-115%);
        }
        to {
          transform: translateY(12%);
        }
      }

      .streak {
        position: absolute;
        bottom: 0;
        left: 0;
        width: 1px;
        height: 26px;
        border-radius: 999px;
        translate: -50% 0;
        opacity: var(--_o);
        background: linear-gradient(
          to top,
          color-mix(in srgb, var(--slate) 42%, transparent),
          transparent
        );
      }

      .ridge {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
      }

      .hills {
        fill: var(--slate);
        opacity: 0.22;
      }

      .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) {
        /* A strobe is the exact shape of animation this setting exists to
           suppress, so the near strike goes entirely — bolt and all. What
           survives is the distant kind: sheet light swelling and dying on a
           doubled clock, plus the rain resting in place and breathing. The
           storm keeps happening; it just moves over the horizon. */
        .bolt {
          animation: none;
          opacity: 0;
        }

        .sky-flash {
          animation-name: sheet-rm;
          animation-duration: calc(var(--t) * 2);
          animation-timing-function: var(--ease-in-out);
        }

        @keyframes sheet-rm {
          0%,
          38%,
          62%,
          100% {
            opacity: 0;
          }
          50% {
            opacity: 0.22;
          }
        }

        .lane {
          animation: none;
        }

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

        @keyframes drizzle-rm {
          0% {
            opacity: 0.08;
          }
          100% {
            opacity: var(--_o);
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <div class="sky-flash" aria-hidden="true"></div>
      <svg class="strike" viewBox="0 0 400 210" preserveAspectRatio="none" aria-hidden="true">
        <path class="bolt glow" d="M258 6 L243 62 L256 70 L234 128 L245 134 L228 172" />
        <path class="bolt fork glow" d="M243 62 L224 96" />
        <path class="bolt" d="M258 6 L243 62 L256 70 L234 128 L245 134 L228 172" />
        <path class="bolt fork" d="M243 62 L224 96" />
      </svg>
      <div class="squall" aria-hidden="true">
        <span class="lane" style="--_x: 7%; --_y: -22%; --_d: 0.16; --_lag: 0.31; --_o: 0.34">
          <i class="streak"></i>
        </span>
        <span class="lane" style="--_x: 21%; --_y: -67%; --_d: 0.205; --_lag: 0.74; --_o: 0.22">
          <i class="streak"></i>
        </span>
        <span class="lane" style="--_x: 38%; --_y: -41%; --_d: 0.17; --_lag: 0.09; --_o: 0.3">
          <i class="streak"></i>
        </span>
        <span class="lane" style="--_x: 54%; --_y: -83%; --_d: 0.21; --_lag: 0.52; --_o: 0.2">
          <i class="streak"></i>
        </span>
        <span class="lane" style="--_x: 71%; --_y: -14%; --_d: 0.155; --_lag: 0.88; --_o: 0.36">
          <i class="streak"></i>
        </span>
        <span class="lane" style="--_x: 88%; --_y: -55%; --_d: 0.19; --_lag: 0.27; --_o: 0.26">
          <i class="streak"></i>
        </span>
      </div>
      <svg class="ridge" viewBox="0 0 400 210" preserveAspectRatio="none" aria-hidden="true">
        <path
          class="hills"
          d="M0 210 L0 176 Q 60 158 120 172 Q 170 182 230 166 Q 300 148 400 174 L400 210 Z"
        />
      </svg>
      <div class="copy">
        <p class="head">A storm, passing</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.