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.

  • 80 lines of CSS
  • 8 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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.

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

.scene {
  perspective: 800px;
}

.rings {
  display: grid;
  place-items: center;
  width: 140px;
  height: 140px;
  transform-style: preserve-3d;
}

/* glowing core: a plain radial gradient (no blur, no filter), gently breathing */
.rings b {
  grid-area: 1 / 1;
  width: 70px;
  height: 70px;
  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 {
  position: relative;
  grid-area: 1 / 1;
  width: var(--d);
  height: var(--d);
  border: 4px 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: -3px;
  border: 1px 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: 132px; --c: #8b6cff; --tilt: 0deg;  --spin: 720deg;  --t: 3.6s; --delay: -0.5s; }
.rings i:nth-of-type(2) { --d: 100px; --c: #ff4d9d; --tilt: 90deg; --spin: -720deg; --t: 2.8s; --delay: -1.3s; }
.rings i:nth-of-type(3) { --d: 68px;  --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);
  }
}

Sass source 99 lines

.d-rings {
  display: grid;
  place-items: center;
  width: 140px;
  height: 140px;
  transform-style: preserve-3d;

  // glowing core: a plain radial gradient (no blur, no filter), gently breathing
  b {
    grid-area: 1 / 1;
    width: 70px;
    height: 70px;
    border-radius: 50%;
    background: radial-gradient(
      circle,
      #fff 0 9%,
      var(--accent-2) 20%,
      color-mix(in srgb, var(--accent-2) 35%, transparent) 38%,
      transparent 68%
    );
    animation: d-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.
  i {
    position: relative;
    grid-area: 1 / 1;
    width: var(--d);
    height: var(--d);
    border: 4px solid transparent;
    border-top-color: var(--c);
    border-bottom-color: var(--c);
    border-radius: 50%;
    animation: d-rings-spin var(--t) linear var(--delay) infinite;

    // faint full track so the ring still reads when the arcs are edge-on
    &::before {
      content: '';
      position: absolute;
      inset: -3px;
      border: 1px 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).
    &:nth-of-type(1) {
      --d: 132px;
      --c: var(--accent);
      --tilt: 0deg;
      --spin: 720deg;
      --t: 3.6s;
      --delay: -0.5s;
    }

    &:nth-of-type(2) {
      --d: 100px;
      --c: var(--hot);
      --tilt: 90deg;
      --spin: -720deg;
      --t: 2.8s;
      --delay: -1.3s;
    }

    &:nth-of-type(3) {
      --d: 68px;
      --c: var(--warm);
      --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 d-rings-spin {
  from {
    transform: rotateZ(var(--tilt)) rotateX(0deg) rotateZ(0deg);
  }

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

@keyframes d-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.