Loaders and spinners

Eight loaders that stay elegant at 3am in a production incident. All of them animate transform and opacity only. In real apps, pair the visual with role="status" so screen readers announce the wait.

1. Dual ring

Two arcs, opposite directions, slightly different speeds.

View CSS
.dual-ring { position: relative; width: 56px; aspect-ratio: 1; }
.dual-ring::before, .dual-ring::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 3px solid transparent; /* arcs = colored sides only */
}
.dual-ring::before {
  border-top-color: #2563EB;
  border-right-color: #2563EB;
  animation: spin 1s linear infinite;
}
.dual-ring::after {
  inset: 9px;
  border-bottom-color: #38BDF8;
  border-left-color: #38BDF8;
  animation: spin 0.8s linear infinite reverse;
}
@keyframes spin { to { transform: rotate(360deg); } }

2. Orbital dots

Only the parent rotates; opacity fakes the trail.

View CSS
/* Rotate the container, not the dots. */
.orbit {
  position: relative;
  width: 56px; aspect-ratio: 1;
  animation: spin 1.4s linear infinite;
}
.orbit i {
  position: absolute;
  width: 9px; aspect-ratio: 1;
  border-radius: 50%;
  background: #38BDF8;
}
.orbit i:nth-child(1) { top: 0; left: 50%; }
.orbit i:nth-child(2) { bottom: 5px; left: 5px;  opacity: 0.6; }
.orbit i:nth-child(3) { bottom: 5px; right: 5px; opacity: 0.3; }

3. Indeterminate bar

translateX + scaleX on one pseudo — material style.

View CSS
.bar {
  height: 4px;
  border-radius: 999px;
  background: #1E293B;
  position: relative;
  overflow: hidden;
}
.bar::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, #2563EB, #38BDF8);
  transform-origin: left;
  animation: indeterminate 1.4s cubic-bezier(.65,0,.35,1) infinite;
}
@keyframes indeterminate {
  0%   { transform: translateX(-100%) scaleX(0.4); }
  45%  { transform: translateX(35%)   scaleX(0.7); }
  100% { transform: translateX(110%)  scaleX(0.3); }
}

4. Skeleton shimmer

Bones + one sweeping highlight. Full layouts in skeletons.html.

View CSS
.shimmer-block b {
  display: block;
  height: 12px;
  border-radius: 6px;
  background: #1E293B;
  position: relative;
  overflow: hidden;   /* clips the sweep */
}
.shimmer-block b::after {
  content: '';
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(90deg,
    transparent, rgba(148, 163, 184, 0.18), transparent);
  animation: sweep 1.6s ease infinite;
}
@keyframes sweep { to { transform: translateX(100%); } }

5. SVG stroke spinner

dasharray draws the arc, dashoffset chases it around.

View CSS
/* <circle r="20"> has circumference ~125.6 */
.stroke-spin { animation: spin 1.8s linear infinite; }
.stroke-spin circle {
  fill: none;
  stroke: #38BDF8;
  stroke-width: 4;
  stroke-linecap: round;
  animation: dash 1.4s ease-in-out infinite;
}
@keyframes dash {
  0%   { stroke-dasharray: 1 125;  stroke-dashoffset: 0; }
  50%  { stroke-dasharray: 90 125; stroke-dashoffset: -35; }
  100% { stroke-dasharray: 90 125; stroke-dashoffset: -124; }
}

6. Typing dots

One keyframe, three delays; the rest window makes the wave.

View CSS
.typing-dots i {
  width: 8px; aspect-ratio: 1;
  border-radius: 50%;
  background: #38BDF8;
  animation: dot-bounce 1.2s ease-in-out infinite;
}
.typing-dots i:nth-child(2) { animation-delay: 0.15s; }
.typing-dots i:nth-child(3) { animation-delay: 0.3s; }
@keyframes dot-bounce {
  /* idle 60% of the loop = the wave shape */
  0%, 60%, 100% { transform: none; opacity: 0.4; }
  30%           { transform: translateY(-6px); opacity: 1; }
}

7. Progress ring with live %

CSS animates the integer, counter() renders it as text.

View CSS
@property --p {
  syntax: '<integer>';  /* registered = animatable */
  initial-value: 0;
  inherits: true;
}
.ring {
  display: grid;
  place-items: center;
  border-radius: 50%;
  background:
    radial-gradient(closest-side, #0A0D14 78%, transparent 79%),
    conic-gradient(#2563EB calc(var(--p) * 1%), #1E293B 0);
  animation: fill-ring 2.6s ease-in-out infinite;
}
.ring::after {
  counter-reset: p var(--p);   /* integer -> text */
  content: counter(p) '%';
}
@keyframes fill-ring {
  0%   { --p: 0; }
  70%, 100% { --p: 100; }      /* hold, then loop */
}

8. Cube flip

Half-turn on X, then on Y. perspective() sells the depth.

View CSS
.cube {
  width: 36px; aspect-ratio: 1;
  border-radius: 6px;
  background: linear-gradient(135deg, #2563EB, #38BDF8);
  animation: cube-flip 1.8s ease-in-out infinite;
}
/* -179.9deg pins the spin direction: at exactly 180deg
   the browser is free to rotate either way. */
@keyframes cube-flip {
  0%   { transform: perspective(120px) rotateX(0); }
  50%  { transform: perspective(120px) rotateX(-179.9deg); }
  100% { transform: perspective(120px) rotateX(-179.9deg)
                    rotateY(-179.9deg); }
}