Back to the library

Orbit dots

Three dots of decreasing opacity circling a track at 1.1s a revolution — an indeterminate loader for waits with no known end. Deliberately quick: a faster spinner makes an identical wait feel shorter. Each dot rides its own rotating layer, so the whole thing is one transform per frame and no position maths.

loadingloop

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>Orbit dots</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;
        --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 — every duration in the document runs off --t. The arm offsets
           and the reduced-motion fade divide by the same --speed, so the trail
           spacing holds at any pace. */
        --cycle: 1.1s;
        --t: calc(var(--cycle) / var(--speed));
        --_lag: calc(0.09s / var(--speed));
        --_calm: calc(1.5s / var(--speed));
        --_calm-lag: calc(0.2s / 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);
      }

      .orbit {
        position: relative;
        width: 72px;
        height: 72px;
      }

      .orbit .track {
        position: absolute;
        inset: 0;
        border-radius: 50%;
        border: 1.5px solid var(--oat);
      }

      /* Each dot rides its own full-size rotating layer, offset by a static
         rotation. Rotating the layer keeps the dot on the circle without any
         per-frame position math. */
      .orbit .arm {
        position: absolute;
        inset: 0;
        animation: spin var(--t) linear infinite;
      }

      .orbit .arm:nth-child(3) {
        animation-delay: calc(var(--_lag) * -1);
      }

      .orbit .arm:nth-child(4) {
        animation-delay: calc(var(--_lag) * -2);
      }

      .orbit .arm span {
        position: absolute;
        top: -4px;
        left: 50%;
        width: 9px;
        height: 9px;
        margin-left: -4.5px;
        border-radius: 50%;
        background: var(--clay);
      }

      .orbit .arm:nth-child(3) span {
        opacity: 0.6;
      }

      .orbit .arm:nth-child(4) span {
        opacity: 0.3;
      }

      /* Indeterminate loaders spin linear — any easing reads as the thing
         stalling once per revolution. 1.1s is deliberately quick: a faster
         spinner makes the same wait feel shorter. */
      @keyframes spin {
        to {
          transform: rotate(360deg);
        }
      }

      @media (prefers-reduced-motion: reduce) {
        .orbit .arm {
          animation: none;
        }

        .orbit .arm:nth-child(3) {
          transform: rotate(120deg);
        }

        .orbit .arm:nth-child(4) {
          transform: rotate(240deg);
        }

        .orbit .arm span {
          animation: dot-fade var(--_calm) ease infinite;
        }

        .orbit .arm:nth-child(3) span {
          animation-delay: var(--_calm-lag);
        }

        .orbit .arm:nth-child(4) span {
          animation-delay: calc(var(--_calm-lag) * 2);
        }

        @keyframes dot-fade {
          0%,
          100% {
            opacity: 0.25;
          }
          40% {
            opacity: 1;
          }
        }
      }
    </style>
  </head>
  <body>
    <div class="orbit" role="status" aria-label="Loading">
      <div class="track"></div>
      <div class="arm"><span></span></div>
      <div class="arm"><span></span></div>
      <div class="arm"><span></span></div>
    </div>
  </body>
</html>