Pure CSS Loaders & patterns #loop #loader #spinner #particles

Orbiting dots loader in pure CSS

Eight glowing dots with comet trails circle a core on three tilted orbits, while the whole atom slowly turns. Every dot counter-rotates so it always faces you, yet still passes behind the core.

  • 100 lines of CSS
  • 35 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

How it works

  1. Each orbit is a circle laid over with rotateX(72deg) and turned with rotateZ(--phi); the dots live inside it, so they share its tilt and pass in front of and behind the core in real depth.
  2. A dot goes round with rotateZ(θ) translateX(r) rotateZ(−θ): step out to the orbit, go round, and turn back by the same angle, so it travels without spinning. Then rotateX(-72deg) rotateZ(-phi) undoes the orbit's tilt so it faces the viewer.
  3. The whole atom turns slowly around Y. The core and every glow turn back by exactly as much with a second animation of the same length (reverse order, reverse signs), so nothing flat ever goes edge-on.
  4. A trail is just more copies of a dot running the same animation a little later (--lag), each smaller and fainter. Every delay has one full lap subtracted, so all of them are negative and no dot waits at the centre when the page loads.
  5. Laps are whole turns and the animations are linear, so the loop has no seam; three lap times (1.6s, 2.4s, 3.3s) and one reversed ring keep it from ever looking in sync.

Key techniques

  • rotateZ(θ) translateX(r) rotateZ(−θ): orbit without turning
  • undo the plane’s tilt = billboard
  • trail copies = same animation, a little later
  • var() inside @keyframes

Copy-paste code

HTML 35 lines

<div class="scene">
  <div class="atom" role="img" aria-label="Loading">
    <b></b>
    <div class="orbit" style="--c:#2ee6d6; --dur:1.6s; --dir:normal; --phi:0deg">
      <i style="--p:0.000; --lag:0"></i>
      <i style="--p:0.000; --lag:1"></i>
      <i style="--p:0.000; --lag:2"></i>
      <i style="--p:0.333; --lag:0"></i>
      <i style="--p:0.333; --lag:1"></i>
      <i style="--p:0.333; --lag:2"></i>
      <i style="--p:0.667; --lag:0"></i>
      <i style="--p:0.667; --lag:1"></i>
      <i style="--p:0.667; --lag:2"></i>
    </div>
    <div class="orbit" style="--c:#ff4d9d; --dur:2.4s; --dir:reverse; --phi:60deg">
      <i style="--p:0.000; --lag:0"></i>
      <i style="--p:0.000; --lag:1"></i>
      <i style="--p:0.000; --lag:2"></i>
      <i style="--p:0.333; --lag:0"></i>
      <i style="--p:0.333; --lag:1"></i>
      <i style="--p:0.333; --lag:2"></i>
      <i style="--p:0.667; --lag:0"></i>
      <i style="--p:0.667; --lag:1"></i>
      <i style="--p:0.667; --lag:2"></i>
    </div>
    <div class="orbit" style="--c:#ffb547; --dur:3.3s; --dir:normal; --phi:120deg">
      <i style="--p:0.000; --lag:0"></i>
      <i style="--p:0.000; --lag:1"></i>
      <i style="--p:0.000; --lag:2"></i>
      <i style="--p:0.500; --lag:0"></i>
      <i style="--p:0.500; --lag:1"></i>
      <i style="--p:0.500; --lag:2"></i>
    </div>
  </div>
</div>

CSS 100 lines

.scene {
  perspective: 800px;
}

/* the whole atom turns slowly; everything flat inside turns back by the same amount */
.atom {
  position: relative;
  width: 152px;
  height: 152px;
  transform-style: preserve-3d;
  animation: atom-turn 16s linear infinite;
}

/* the core */
.atom b {
  position: absolute;
  inset: calc(50% - 20px);
  border-radius: 50%;
  background: radial-gradient(circle at 40% 36%, #fff 0 7%, #c5b6ff 20%, #8b6cff 46%, #463680 70%, transparent 72%);
  animation: atom-face 16s linear infinite;
}

/* a soft halo that breathes (a gradient, no blur) */
.atom b::before {
  content: '';
  position: absolute;
  inset: -16px;
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(139 108 255 / 0.45), transparent);
  animation: atom-breathe 1.6s ease-in-out infinite alternate;
}

/* an orbit: a circle laid over by 72°, its axis turned by --phi */
.orbit {
  position: absolute;
  inset: 0;
  border-radius: 50%;
  transform-style: preserve-3d;
  transform: rotateZ(var(--phi)) rotateX(72deg);
}

/* the dashed track */
.orbit::before {
  content: '';
  position: absolute;
  inset: 0;
  border: 1px dashed color-mix(in srgb, var(--c) 45%, transparent);
  border-radius: 50%;
}

/* a dot: an invisible carrier going round; --p spaces the dots, --lag makes the trail
   (same animation, a little later). One whole lap is subtracted so every delay is negative. */
.orbit i {
  position: absolute;
  top: calc(50% - 6px);
  left: calc(50% - 6px);
  width: 12px;
  height: 12px;
  transform-style: preserve-3d;
  animation: dot-orbit var(--dur) linear infinite var(--dir);
  animation-delay: calc(var(--lag) * var(--dur) * 0.017 - var(--p) * var(--dur) - var(--dur));
}

/* the glow; trail copies are smaller and fainter */
.orbit i::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: radial-gradient(circle,
    color-mix(in srgb, var(--c) 30%, #fff) 0 7%,
    var(--c) 24% 38%,
    color-mix(in srgb, var(--c) 30%, transparent) 56%,
    transparent 70%);
  opacity: calc(1 - var(--lag) * 0.3);
  scale: calc(1.25 - var(--lag) * 0.25);
  animation: atom-face 16s linear infinite;
}

@keyframes atom-turn {
  from { transform: rotateX(-16deg) rotateY(0deg); }
  to   { transform: rotateX(-16deg) rotateY(360deg); }
}

/* exactly undoes atom-turn: same duration, reversed order and signs */
@keyframes atom-face {
  from { transform: rotateY(0deg) rotateX(16deg); }
  to   { transform: rotateY(-360deg) rotateX(16deg); }
}

/* read right to left: undo the orbit's tilt, cancel the lap, step out, go round */
@keyframes dot-orbit {
  from { transform: rotateZ(0deg) translateX(76px) rotateZ(0deg) rotateX(-72deg) rotateZ(calc(var(--phi) * -1)); }
  to   { transform: rotateZ(360deg) translateX(76px) rotateZ(-360deg) rotateX(-72deg) rotateZ(calc(var(--phi) * -1)); }
}

@keyframes atom-breathe {
  from { opacity: 0.55; transform: scale(0.85); }
  to   { opacity: 1; transform: scale(1.1); }
}

Sass source 153 lines

@use 'sass:list';

$r: 76px; // orbit radius
$turn: 16s; // one slow turn of the whole atom
$tilt: -16deg; // we look at it from a little above
// [colour, lap time, direction, where the ring's axis points (around Z)]
$rings: (
  (var(--accent-2), 1.6s, normal, 0deg),
  (var(--hot), 2.4s, reverse, 60deg),
  (var(--warm), 3.3s, normal, 120deg)
);

// The whole atom turns slowly around Y. Everything flat inside it (the core, each dot) turns back
// by exactly as much, so it always faces the viewer instead of going edge-on.
.d-dotorbit {
  position: relative;
  width: $r * 2;
  height: $r * 2;
  transform-style: preserve-3d;
  animation: d-dotorbit-turn $turn linear infinite;

  // the core
  b {
    position: absolute;
    inset: calc(50% - 20px);
    border-radius: 50%;
    background: radial-gradient(
      circle at 40% 36%,
      #fff 0 7%,
      color-mix(in srgb, var(--accent) 45%, #fff) 20%,
      var(--accent) 46%,
      color-mix(in srgb, var(--accent) 50%, #000) 70%,
      transparent 72%
    );
    animation: d-dotorbit-face $turn linear infinite;

    // a soft halo that breathes (a gradient, no blur)
    &::before {
      content: '';
      position: absolute;
      inset: -16px;
      border-radius: 50%;
      background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 45%, transparent), transparent);
      animation: d-dotorbit-breathe 1.6s ease-in-out infinite alternate;
    }
  }

  // An orbit: a circle laid over by 72° (an ellipse to us), its axis turned by --phi.
  &__ring {
    position: absolute;
    inset: 0;
    border-radius: 50%;
    transform-style: preserve-3d;
    transform: rotateZ(var(--phi)) rotateX(72deg);

    @for $k from 1 through list.length($rings) {
      $ring: list.nth($rings, $k);

      &:nth-of-type(#{$k}) {
        --c: #{list.nth($ring, 1)};
        --dur: #{list.nth($ring, 2)};
        --dir: #{list.nth($ring, 3)};
        --phi: #{list.nth($ring, 4)};
      }
    }

    // the dashed track
    &::before {
      content: '';
      position: absolute;
      inset: 0;
      border: 1px dashed color-mix(in srgb, var(--c) 45%, transparent);
      border-radius: 50%;
    }

    // A dot is an invisible carrier that travels round the orbit; its ::before is the glow.
    // --p spaces the dots round the ring, --lag (0, 1, 2) makes the trail: the same animation a
    // little later, so it is always a little behind. One whole cycle is subtracted so every delay
    // is negative and nothing waits at the centre before it starts.
    i {
      position: absolute;
      top: calc(50% - 6px);
      left: calc(50% - 6px);
      width: 12px;
      height: 12px;
      transform-style: preserve-3d;
      animation: d-dotorbit-orbit var(--dur) linear infinite var(--dir);
      animation-delay: calc(var(--lag) * var(--dur) * 0.017 - var(--p) * var(--dur) - var(--dur));

      &::before {
        content: '';
        position: absolute;
        inset: 0;
        border-radius: 50%;
        background: radial-gradient(
          circle,
          color-mix(in srgb, var(--c) 30%, #fff) 0 7%,
          var(--c) 24% 38%,
          color-mix(in srgb, var(--c) 30%, transparent) 56%,
          transparent 70%
        );
        opacity: calc(1 - var(--lag) * 0.3);
        scale: calc(1.25 - var(--lag) * 0.25);
        animation: d-dotorbit-face $turn linear infinite;
      }
    }
  }
}

@keyframes d-dotorbit-turn {
  from {
    transform: rotateX($tilt) rotateY(0deg);
  }

  to {
    transform: rotateX($tilt) rotateY(360deg);
  }
}

// exactly undoes d-dotorbit-turn (same duration, reversed order and signs)
@keyframes d-dotorbit-face {
  from {
    transform: rotateY(0deg) rotateX(-$tilt);
  }

  to {
    transform: rotateY(-360deg) rotateX(-$tilt);
  }
}

// Read right to left: undo the ring's tilt and turn (so the dot faces out of the atom again),
// cancel the lap (rotateZ(-θ)), step out to the orbit, then go round it (rotateZ(θ)).
@keyframes d-dotorbit-orbit {
  from {
    transform: rotateZ(0deg) translateX($r) rotateZ(0deg) rotateX(-72deg) rotateZ(calc(var(--phi) * -1));
  }

  to {
    transform: rotateZ(360deg) translateX($r) rotateZ(-360deg) rotateX(-72deg) rotateZ(calc(var(--phi) * -1));
  }
}

@keyframes d-dotorbit-breathe {
  from {
    opacity: 0.55;
    transform: scale(0.85);
  }

  to {
    opacity: 1;
    transform: scale(1.1);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.