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.

  • 107 lines of CSS
  • 35 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

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.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: the orbits are 76 units in radius and a dot 12 across, so the atom is the same share of a gallery card, the editor and a recording canvas.

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 107 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the atom is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.39vmin;
  /* where the loop starts: every animation is set 36s into its run, one shared moment, so the
     timing between them is kept. There the dots are spread round the core, and a paused atom
     (which shows this first frame) is centred; at 0s they bunch on one side */
  --t0: -36s;
  perspective: calc(800 * var(--u));
}

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

/* the core */
.atom b {
  position: absolute;
  inset: calc(50% - 20 * var(--u));
  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 var(--t0) infinite;
}

/* a soft halo that breathes (a gradient, no blur) */
.atom b::before {
  content: '';
  position: absolute;
  inset: calc(-16 * var(--u));
  border-radius: 50%;
  background: radial-gradient(closest-side, rgb(139 108 255 / 0.45), transparent);
  animation: atom-breathe 1.6s ease-in-out var(--t0) 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: calc(1 * var(--u)) 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% - 6 * var(--u));
  left: calc(50% - 6 * var(--u));
  width: calc(12 * var(--u));
  height: calc(12 * var(--u));
  transform-style: preserve-3d;
  animation: dot-orbit var(--dur) linear infinite var(--dir);
  animation-delay: calc(var(--t0) + 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 var(--t0) 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(calc(76 * var(--u))) rotateZ(0deg) rotateX(-72deg) rotateZ(calc(var(--phi) * -1)); }
  to   { transform: rotateZ(360deg) translateX(calc(76 * var(--u))) 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); }
}
Standalone snippet · plain CSS, no build step, no dependencies.