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.

loadingloopspinnerindeterminate

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 {
        --ivory: #faf9f5;
        --slate: #141413;
        --clay: #d97757;
        --oat: #e3dacc;
      }

      * {
        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 1.1s linear infinite;
      }

      .orbit .arm:nth-child(3) {
        animation-delay: -0.09s;
      }

      .orbit .arm:nth-child(4) {
        animation-delay: -0.18s;
      }

      .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 1.5s ease infinite;
        }

        .orbit .arm:nth-child(3) span {
          animation-delay: 0.2s;
        }

        .orbit .arm:nth-child(4) span {
          animation-delay: 0.4s;
        }

        @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>