Back to the library

Ambient — drifting colour field

Variant: Colour field

The soft-gradient background, built so it cannot be caught repeating. Each wash is an oversized square with its blob sitting off-centre, rotated about the panel’s middle — a rotation is the only closed path with no waypoints to ease through, so there is no stop-start anywhere in the drift and the whole thing costs one composited property per layer. The two run counter at 19s and 23s, and because those are coprime the pair only returns to its opening arrangement once every 437 seconds. An ambient loop is judged on whether a reader can tell you where it was ten seconds ago, and this one is running under copy that already has their attention.

ambientloop

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>Ambient — drifting colour field</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 — 19s and 23s are coprime, so the two washes only return to
           their opening arrangement once every 437 seconds. Nobody watches a
           background for seven minutes, which is the whole reason for the odd
           numbers: the loop exists but cannot be caught. */
        --cycle: 19s;
        --t: calc(var(--cycle) / var(--speed));
        --_second: calc(23s / var(--speed));
      }

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

      /* Each wash is a square far wider than the panel with its blob sitting
         off-centre, so rotating it about the panel's middle carries the colour
         round a perfect circle. A rotation is the only closed path with no
         waypoints in it — nothing to ease through, no corner to soften, and one
         composited property per layer for the whole drift. */
      .wash {
        position: absolute;
        top: 50%;
        left: 50%;
        width: 178%;
        aspect-ratio: 1;
        translate: -50% -50%;
        animation: turn var(--t) linear infinite;
      }

      .wash-a {
        background: radial-gradient(
          30% 30% at 34% 38%,
          color-mix(in srgb, var(--clay) 32%, transparent),
          transparent 100%
        );
      }

      /* Counter-rotating on the longer clock. Same direction and the pair would
         read as one rigid pattern turning rather than two clouds passing. */
      .wash-b {
        background: radial-gradient(
          34% 34% at 68% 62%,
          color-mix(in srgb, var(--olive) 26%, transparent),
          transparent 100%
        );
        animation-duration: var(--_second);
        animation-direction: reverse;
      }

      @keyframes turn {
        to {
          rotate: 1turn;
        }
      }

      /* The copy is not decoration. An ambient field is only ever seen under
         something, and contrast against real text at real size is the only
         constraint it actually has to meet. */
      .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 travel goes and the field still has to be alive, because a frozen
           gradient is indistinguishable from a rendering mistake. The two washes
           breathe on the same coprime pair, so the surface still never quite
           settles into a state it has held before. */
        .wash {
          animation-name: soften;
          animation-timing-function: ease;
        }

        @keyframes soften {
          0%,
          100% {
            opacity: 1;
          }
          50% {
            opacity: 0.4;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <div class="wash wash-a" aria-hidden="true"></div>
      <div class="wash wash-b" aria-hidden="true"></div>
      <div class="copy">
        <p class="head">Everything is quiet</p>
        <p class="sub">Ambient motion. 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.