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); }
}