Pure CSS Loaders & patterns #loop #loader #spinner

Chasing rings loader in pure CSS

Three rings of paired arcs, each tumbling around a different axis while its arcs chase each other around the ring, circling a breathing core.

  • 84 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. A ring is a circle whose border is transparent except border-top-color and border-bottom-color — two arcs on opposite sides, no SVG needed.
  2. Each ring composes three rotations in one keyframe: rotateZ(tilt) picks the axis it tumbles around, rotateX does the tumble, and a trailing rotateZ(spin) chases the arcs around the ring as it tumbles — order matters, read it right to left.
  3. One @keyframes block serves all three rings: var(--tilt) and var(--spin) inside the keyframe let each ring plug in its own numbers.
  4. --spin is always a whole number of turns (720deg, -720deg, 1080deg) so the ring's rotation ends exactly where it started — that is what makes the loop seamless.
  5. The core is a plain breathing radial-gradient, no blur or filter, which keeps the whole loader cheap to animate.
  6. Every length is a multiple of one base unit, --u, tied to the canvas: the rings are 132, 100 and 68 units across, so the loader is the same share of a gallery card, the editor and a recording canvas.

Key techniques

  • transparent border + two coloured sides = arcs
  • rotateZ(tilt) rotateX(tumble) rotateZ(spin)
  • var() inside @keyframes
  • whole turns only = seamless

Copy-paste code

HTML 8 lines

<div class="scene">
  <div class="rings" role="img" aria-label="Loading">
    <b></b>
    <i></i>
    <i></i>
    <i></i>
  </div>
</div>

CSS 84 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the loader is the same share of a
     gallery card, the editor, a full screen and a recording canvas */
  --u: 0.42vmin;
  perspective: calc(800 * var(--u));
}

.rings {
  display: grid;
  place-items: center;
  width: calc(140 * var(--u));
  height: calc(140 * var(--u));
  transform-style: preserve-3d;
}

/* glowing core: a plain radial gradient (no blur, no filter), gently breathing */
.rings b {
  grid-area: 1 / 1;
  width: calc(70 * var(--u));
  height: calc(70 * var(--u));
  border-radius: 50%;
  background: radial-gradient(
    circle,
    #fff 0 9%,
    #2ee6d6 20%,
    color-mix(in srgb, #2ee6d6 35%, transparent) 38%,
    transparent 68%
  );
  animation: rings-core 1.4s ease-in-out infinite alternate;
}

/* a ring is a circle whose border is transparent except top and bottom: two arcs on opposite
   sides, tapering at the ends, that chase each other as the ring spins in its own plane */
.rings i {
  box-sizing: content-box; /* a ring is its hole, its border drawn outside it: by design, on any page */
  position: relative;
  grid-area: 1 / 1;
  width: var(--d);
  height: var(--d);
  border: calc(4 * var(--u)) solid transparent;
  border-top-color: var(--c);
  border-bottom-color: var(--c);
  border-radius: 50%;
  animation: rings-spin var(--t) linear var(--delay) infinite;
}

/* faint full track so the ring still reads when the arcs are edge-on */
.rings i::before {
  content: '';
  position: absolute;
  inset: calc(-3 * var(--u));
  border: calc(1 * var(--u)) solid color-mix(in srgb, var(--c) 28%, transparent);
  border-radius: 50%;
}

/* --tilt picks the axis the ring tumbles around (0 = X, 90 = Y, 45 = the diagonal),
   --spin is how far the arcs travel around the ring per tumble (whole turns only → seamless) */
.rings i:nth-of-type(1) { --d: calc(132 * var(--u)); --c: #8b6cff; --tilt: 0deg;  --spin: 720deg;  --t: 3.6s; --delay: -0.5s; }
.rings i:nth-of-type(2) { --d: calc(100 * var(--u)); --c: #ff4d9d; --tilt: 90deg; --spin: -720deg; --t: 2.8s; --delay: -1.3s; }
.rings i:nth-of-type(3) { --d: calc(68 * var(--u));  --c: #ffb547; --tilt: 45deg; --spin: 1080deg; --t: 2.2s; --delay: -0.2s; }

/* read right to left: spin the arcs in the ring's plane, tumble the plane around X,
   then turn that X axis to wherever --tilt says */
@keyframes rings-spin {
  from {
    transform: rotateZ(var(--tilt)) rotateX(0deg) rotateZ(0deg);
  }

  to {
    transform: rotateZ(var(--tilt)) rotateX(360deg) rotateZ(var(--spin));
  }
}

@keyframes rings-core {
  from {
    opacity: 0.7;
    transform: scale(0.85);
  }

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