Back to the library

Ambient — dust motes

Variant: Dust motes

Sixteen motes falling through a shaft of light at sixteen durations that share no simple ratio — 0.71 to 1.63 of the base clock — because a field seeded with random numbers clumps and a field on tidy multiples visibly falls in step. Every lane carries a negative animation-delay, so the panel is already populated on its first frame instead of spending nine seconds filling from the top; an ambient background has no opening beat. Each mote rides a lane the full height of the panel, which is the only reason a percentage travel stays fluid on a 4px dot, and its opacity envelope opens and closes well inside the frame so nothing is ever seen appearing at an edge. Sway runs on a separate period from the fall, or every path would be the same repeating diagonal.

ambientloopstagger

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 — dust motes</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 mote multiplies it by its own
           factor between 0.71 and 1.63, and no two of those factors share a
           simple ratio, which is what stops the field ever falling in step. */
        --cycle: 9s;
        --t: calc(var(--cycle) / var(--speed));

        --_dot: 4px;
        --_sway: 7px;

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

      /* Static. The motes need a reason to be catching light, and a shaft that
         also moved would be a second animation competing with the first. */
      .shaft {
        position: absolute;
        inset: 0;
        background: linear-gradient(
          104deg,
          transparent 20%,
          color-mix(in srgb, var(--oat) 74%, transparent) 52%,
          transparent 80%
        );
      }

      /* Each mote hangs off the bottom of a lane the full height of the panel,
         so translateY percentages resolve against the panel rather than against
         a 4px dot. That is the only reason the travel stays fluid across every
         preview size without a single hard-coded distance. */
      .lane {
        position: absolute;
        top: 0;
        left: var(--_x);
        width: 0;
        height: 100%;
        transform: translateY(var(--_y));
        animation: fall calc(var(--t) * var(--_d)) linear infinite;
        /* Negative, so the field is already populated on the first frame. A
           positive delay would spend the first nine seconds filling from the
           top, and an ambient background has no opening beat. */
        animation-delay: calc(var(--t) * var(--_lag) * -1);
      }

      .lane > i {
        position: absolute;
        bottom: 0;
        left: 0;
        width: calc(var(--_dot) * var(--_s));
        height: calc(var(--_dot) * var(--_s));
        border-radius: 50%;
        translate: -50% 0;
        /* Big motes are bright and small ones are dim. It is the only depth cue
           in the piece, and it costs nothing to run. */
        opacity: calc(0.26 + var(--_s) * 0.28);
        background: var(--clay-d);
        animation: sway calc(var(--t) * var(--_w)) var(--ease-in-out) infinite alternate;
      }

      /* The envelope opens and closes well inside the frame, so no mote is ever
         seen appearing or being cut off at an edge. */
      @keyframes fall {
        0% {
          transform: translateY(-112%);
          opacity: 0;
        }
        11%,
        82% {
          opacity: 1;
        }
        100% {
          transform: translateY(12%);
          opacity: 0;
        }
      }

      /* Sway runs on its own period, unrelated to the fall. Tie the two together
         and every mote traces the same repeating diagonal. */
      @keyframes sway {
        from {
          transform: translateX(calc(var(--_sway) * -1));
        }
        to {
          transform: translateX(var(--_sway));
        }
      }

      .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, which is why that
           offset exists at all — cut the animation without it and sixteen motes
           stack up on the bottom edge. They breathe instead, on the same
           per-mote periods and the same authored offsets, so the field is still
           incoherent rather than blinking as one. */
        .lane {
          animation: none;
        }

        .lane > i {
          animation-name: glow;
          animation-delay: calc(var(--t) * var(--_lag) * -1);
          transform: none;
        }

        @keyframes glow {
          0% {
            opacity: 0.18;
          }
          100% {
            opacity: 0.5;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="panel">
      <div class="shaft" aria-hidden="true"></div>
      <div class="field" aria-hidden="true">
        <span
          class="lane"
          style="--_x: 4%; --_y: -12%; --_d: 1; --_lag: 0.03; --_s: 1.3; --_w: 0.61"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 11%; --_y: -71%; --_d: 1.37; --_lag: 0.62; --_s: 0.8; --_w: 0.74"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 15%; --_y: -38%; --_d: 0.83; --_lag: 0.29; --_s: 1.1; --_w: 0.53"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 23%; --_y: -86%; --_d: 1.19; --_lag: 0.81; --_s: 0.7; --_w: 0.67"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 29%; --_y: -22%; --_d: 1.53; --_lag: 0.14; --_s: 1.4; --_w: 0.58"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 33%; --_y: -57%; --_d: 0.94; --_lag: 0.47; --_s: 0.9; --_w: 0.79"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 41%; --_y: -91%; --_d: 1.29; --_lag: 0.93; --_s: 1.2; --_w: 0.64"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 47%; --_y: -33%; --_d: 0.79; --_lag: 0.36; --_s: 0.75; --_w: 0.71"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 51%; --_y: -66%; --_d: 1.11; --_lag: 0.7; --_s: 1.35; --_w: 0.56"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 59%; --_y: -47%; --_d: 1.44; --_lag: 0.21; --_s: 0.85; --_w: 0.77"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 65%; --_y: -78%; --_d: 0.88; --_lag: 0.55; --_s: 1.05; --_w: 0.68"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 69%; --_y: -18%; --_d: 1.63; --_lag: 0.09; --_s: 0.95; --_w: 0.51"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 77%; --_y: -62%; --_d: 0.71; --_lag: 0.74; --_s: 1.25; --_w: 0.66"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 83%; --_y: -41%; --_d: 1.23; --_lag: 0.42; --_s: 0.72; --_w: 0.73"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 87%; --_y: -84%; --_d: 1.05; --_lag: 0.88; --_s: 1.15; --_w: 0.59"
        >
          <i></i>
        </span>
        <span
          class="lane"
          style="--_x: 95%; --_y: -28%; --_d: 0.97; --_lag: 0.26; --_s: 0.88; --_w: 0.63"
        >
          <i></i>
        </span>
      </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.