Back to the library

Weather — fog bank

Variant: Fog

The one weather that adds nothing to a scene — it subtracts, which is why the piece is built as two translucent banks sliding between three pines that never move. Each bank is one tile of soft blobs repeated exactly twice across a 200% strip travelling -50%: the marquee seam trick, borrowed because fog shares a ticker’s constraint of never being seen starting over. The banks run at periods of 13 to 8, coprime, so the moment both thin patches align and the far pine comes fully back is genuinely rare — and it is the thin patches doing the work, because you notice fog when something you were looking at returns. Linear throughout: a bank crossing a window has no reason to slow down. Reach for it behind a hero or a quiet dashboard corner, where what sits beneath should feel distant rather than absent.

ambientweatherloopsvgsweep

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 — fog</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 — the far bank takes the full cycle to loop, the near one 8/13
           of it. 8 and 13 share no factor, so the two banks only realign after
           thirteen near-passes (eight far ones) — which is why the moment both
           thin patches line up and the far pine comes fully back is genuinely
           rare. */
        --cycle: 26s;
        --t: calc(var(--cycle) / var(--speed));

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

        --_fog-thick: color-mix(in srgb, var(--paper) 94%, transparent);
        --_fog-mid: color-mix(in srgb, var(--paper) 76%, transparent);
        --_fog-thin: color-mix(in srgb, var(--paper) 56%, transparent);
      }

      * {
        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);
      }

      /* Not paper, and it cannot be: the fog is paper-coloured, and white on
         white is invisible. The warm grade from sky to ground is the value the
         banks are read against — remove it and the piece is two pines and
         nothing else. */
      .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: linear-gradient(
          180deg,
          color-mix(in srgb, var(--oat) 38%, var(--paper)),
          color-mix(in srgb, var(--oat) 14%, var(--paper)) 62%,
          var(--paper)
        );
      }

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

      .ground {
        stroke: var(--oat);
        stroke-width: 1;
        vector-effect: non-scaling-stroke;
        opacity: 0.9;
      }

      /* Aerial perspective before any fog arrives: the far pine is already
         smaller, higher and lighter. The banks deepen a gradient the still
         drawing has to state first, or the occlusion has nothing to grade. */
      .pine {
        fill: var(--olive);
      }

      .pine.far {
        opacity: 0.42;
      }

      .pine.near {
        opacity: 0.8;
      }

      /* Each bank is one tile of soft blobs repeated exactly twice across a
         200%-wide strip, travelling -50% — the marquee seam trick, borrowed
         because fog has the same problem as a ticker: it must never be seen
         starting over. The blobs sit at uneven spacings inside the tile so
         density varies along the drift, and it is the thin patches, not the
         thick ones, that do the work — you notice fog when something you were
         looking at comes back. */
      .bank {
        position: absolute;
        left: 0;
        width: 200%;
        background-repeat: repeat-x;
        background-size: 50% 100%;
        animation: drift var(--t) linear infinite;
      }

      /* Linear: a bank crossing a window has no reason to slow down. Fog that
         eases is fog visibly being animated. */
      @keyframes drift {
        to {
          transform: translateX(-50%);
        }
      }

      /* Every blob's visible extent — centre ± radius × its transparent stop —
         sits inside the tile, because a radial gradient is clipped at its
         tile's edge: let one poke out while still opaque and a hard vertical
         seam rides across the panel for the whole pass. The gaps this leaves
         at the tile edges are free thin patches. */
      .bank.behind {
        top: 22%;
        height: 46%;
        background-image:
          radial-gradient(42% 46% at 32% 52%, var(--_fog-mid), transparent 70%),
          radial-gradient(30% 40% at 51% 44%, var(--_fog-thin), transparent 72%),
          radial-gradient(24% 34% at 82% 56%, var(--_fog-mid), transparent 68%);
      }

      /* The near bank is denser and 13/8 faster — closer fog crosses the
         window quicker for the same wind, which is the parallax that gives a
         flat drawing its third dimension. */
      .bank.front {
        top: 34%;
        height: 54%;
        animation-duration: calc(var(--t) * 8 / 13);
        background-image:
          radial-gradient(44% 48% at 33% 50%, var(--_fog-thick), transparent 70%),
          radial-gradient(34% 42% at 62% 58%, var(--_fog-mid), transparent 72%),
          radial-gradient(26% 36% at 80% 46%, var(--_fog-thick), transparent 66%);
      }

      .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 drift stops and the fog thickens and thins in place instead —
           occlusion, the piece's whole subject, carried by opacity alone. The
           two banks breathe in opposite phase on a slowed clock, so the far
           pine still comes and goes without anything crossing the frame. */
        .bank {
          animation: thicken-rm calc(var(--t) / 2) var(--ease-in-out) infinite alternate;
          transform: none;
        }

        .bank.front {
          animation-delay: calc(var(--t) / -2);
        }

        @keyframes thicken-rm {
          0% {
            opacity: 0.45;
          }
          100% {
            opacity: 0.9;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <svg class="scene" viewBox="0 0 400 210" preserveAspectRatio="none" aria-hidden="true">
        <g class="pine far" transform="translate(298 148) scale(0.62)">
          <path d="M-18 0 L0 -30 L18 0 Z M-13 -20 L0 -44 L13 -20 Z M-9 -38 L0 -58 L9 -38 Z" />
        </g>
      </svg>
      <div class="bank behind" aria-hidden="true"></div>
      <svg class="scene" viewBox="0 0 400 210" preserveAspectRatio="none" aria-hidden="true">
        <line class="ground" x1="0" y1="176" x2="400" y2="176" />
        <g class="pine near" transform="translate(84 176)">
          <path d="M-18 0 L0 -30 L18 0 Z M-13 -20 L0 -44 L13 -20 Z M-9 -38 L0 -58 L9 -38 Z" />
        </g>
        <g class="pine near" transform="translate(206 176) scale(0.78)">
          <path d="M-18 0 L0 -30 L18 0 Z M-13 -20 L0 -44 L13 -20 Z M-9 -38 L0 -58 L9 -38 Z" />
        </g>
      </svg>
      <div class="bank front" aria-hidden="true"></div>
      <div class="copy">
        <p class="head">Fog coming in</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.