Introduction
This loader reads as a washing machine mid-cycle: a boxy white body, two control knobs, a stubby foot, and a glass porthole spinning fast enough to blur the reflection. None of it is an image or an SVG. The entire body is one element with layered background-image gradients doing the work of a control panel, and the porthole is a second element whose diagonal split and spin speed are the only two things that sell the illusion of a tumbling drum.
The technique rests on four ideas: a single element can carry an unlimited stack of background-image layers, each with its own background-position and background-size, so a control panel with a groove, a slot, and a switch never needs extra markup — a radial-gradient with a hard stop between two colors reads as a solid circular knob rather than a soft glow, because the color transition happens in a one-percent band instead of fading gradually — a repeating shadow declared once on box-shadow can stand in for a second copy of an element, avoiding a duplicate div for something as small as a foot — and a spin animation that changes rotation speed *between* keyframes, not just its total distance, is what makes a wash cycle feel like it is speeding up and slowing down rather than rotating at one constant rate.
How to stack several background gradients on one element to build a control panel without extra markup — how a hard-stop radial-gradient fakes a solid knob — how box-shadow can double as a cheap clone of a small decorative shape — how one keyframe animation with uneven percentage steps can feel like acceleration and deceleration instead of constant speed — how to fix a keyframe range that silently overlaps itself — and how pulling every color into a CSS custom property turns a one-off decoration into something you can retheme in a single edit.
Step 1 — Splitting the Machine into Two Elements
The whole loader only needs two elements: an outer .washing-machine for the boxy body, and an inner .washing-machine__drum for the circular porthole. Everything else — the control panel groove, the two knobs, the foot — is drawn with background-image layers on the outer element rather than as separate children. That keeps the markup at three lines and pushes all the visual complexity into CSS, where it is easier to theme and animate independently.
<div class="washing-machine">
<div class="washing-machine__knobs"></div>
<div class="washing-machine__feet"></div>
<div class="washing-machine__drum"></div>
</div>Even though the diagram above shows four elements, only the outer body and the drum actually animate — the knobs and feet layers are visually static, split into their own elements mainly so each concern (panel, knobs, feet, drum) has one obvious place to live rather than one element carrying five unrelated background layers at once.
Step 2 — A Hard-Stop Radial Gradient Reads as a Solid Knob
A normal radial gradient fades smoothly from center to edge, which looks like a soft light, not a physical dial. The trick for a solid-looking knob is a hard color stop: the same color repeated at two adjacent percentages, so the gradient snaps from one flat color to another instead of blending. 25% to 26% is close enough that the eye reads a crisp edge, and the second stop fades the knob back to transparent so it drops cleanly onto whatever is behind it.
.washing-machine__knobs {
position: absolute;
inset: 0;
background-repeat: no-repeat;
background-image:
radial-gradient(ellipse at center,
var(--wm-knob-outer) 25%,
var(--wm-knob-inner) 26%,
var(--wm-knob-inner) 50%,
transparent 55%);
background-position: 55px 3px, 75px 3px, 95px 3px;
background-size: 15px 15px, 15px 15px, 15px 15px;
}Three background-position coordinates on one background-image declaration is the same gradient repeated three times at three spots — one line of CSS, three knobs, no extra markup.
Step 3 — A Shadow Standing in for a Duplicate Element
The machine needs two small feet at the bottom corners, but only one is ever drawn as an actual shape. box-shadow accepts an offset and a color like any other shadow, and nothing requires that offset to look like a shadow — at 102px 0 with no blur, it is indistinguishable from a second copy of the element sitting 102px to the right. This is a useful habit any time a shape needs to repeat exactly once more: a shadow is cheaper than a second element and never drifts out of sync with the original.
.washing-machine__feet {
position: absolute;
left: 5px;
top: 100%;
width: 7px;
height: 5px;
background: var(--wm-foot);
border-radius: 0 0 4px 4px;
box-shadow: 102px 0 var(--wm-foot); /* the second foot */
}Step 4 — A Diagonal Split Sells the Curve of the Glass
The porthole is a circle with a thick, dark border, an inset shadow for depth, and a background made of two gradients: a repeating diagonal stripe for the reflective "streak" of glass, and a 135-degree two-color split behind it for the water and the machine's frame. Layering a repeating gradient over a static one is what gives the glass its sense of curvature — the stripe reads as a highlight sliding across a rounded surface rather than a flat tint.
.washing-machine__drum {
width: 95px;
height: 95px;
border-radius: 50%;
border: 10px solid var(--wm-drum-frame);
box-shadow:
0 0 0 4px var(--wm-shadow) inset,
0 0 6px 6px var(--wm-shadow-deep) inset;
background-color: var(--wm-drum-water);
background-repeat: repeat, no-repeat;
background-size: 30px 100%, 90px 80px;
background-image:
linear-gradient(to right,
rgba(0,0,0,.27) 0%, rgba(0,0,0,.27) 49%,
transparent 50%, transparent 100%),
linear-gradient(135deg, var(--wm-drum-glass-a) 50%, var(--wm-drum-glass-b) 51%);
animation: wm-spin var(--wm-cycle-duration) ease-in-out infinite;
}
@keyframes wm-spin {
0% { transform: rotate(0deg); }
50% { transform: rotate(360deg); }
75% { transform: rotate(750deg); }
100% { transform: rotate(1800deg); }
}The four keyframe steps are not evenly spaced in rotation distance, and that unevenness is the point. The drum turns one full 360° over the first half of the cycle, then covers a much larger distance — 390° — in the next quarter, then a huge 1050° in the final quarter. Distance divided by time is speed, so a rotation that covers more degrees in less time is a rotation that is visibly accelerating; the eye reads this as the wash cycle spinning up before it settles, without a single line of JavaScript driving it.
Step 5 — Overlapping Percentages Produce Undefined Behavior
The outer body has its own animation: a tiny rotational wobble that makes the whole machine look like it is rocking under the spinning drum's weight. The original version of this rule listed two groups of percentages that both touched the same values — one selector for 50%, 75%, 84%, 92% and another for 65%, 80%, 88%, 96% — but a keyframe rule can only hold one transform per percentage, and when a later selector in the same block reuses a percentage claimed by an earlier one, only one of them wins. The fix is making sure no two selectors in the same @keyframes block share a percentage.
@keyframes wm-shake {
0%, 50%, 100% { transform: rotate(0deg); }
50.001%, 75%, 84%, 92% { transform: rotate(-0.5deg); }
65%, 80%, 88%, 96% { transform: rotate(0.5deg); }
}The bug here never throws an error or shows up in a linter — the stylesheet is perfectly valid CSS. It only shows up as a visual glitch: a wobble that hitches or jumps at exactly the 50% mark, because the browser has to silently pick a winner between two conflicting rules claiming the same moment in the timeline. Nudging one boundary by a thousandth of a percent, as above, is a small, safe way to break the tie without changing the timing anyone can perceive.
Step 6 — Pulling Every Color into One Token Block
Every gradient above references a --wm-* custom property instead of a literal hex value. This does not change how anything looks on first render, but it changes how much work a retheme takes: swapping the machine to a stainless-steel or a pastel-pink finish is a matter of editing one :root block, not hunting through six gradient declarations spread across three rules.
:root {
--wm-body: #ffffff;
--wm-shadow: #999999;
--wm-shadow-deep: rgba(0, 0, 0, 0.25);
--wm-foot: #aaaaaa;
--wm-knob-outer: #aaaaaa;
--wm-knob-inner: #eeeeee;
--wm-drum-frame: #dddddd;
--wm-drum-water: #bbdefb;
--wm-drum-glass-a: #64b5f6;
--wm-drum-glass-b: #607d8b;
--wm-cycle-duration: 3s;
}The same block also carries --wm-cycle-duration, shared by both the body's shake and the drum's spin. Because both animations read from one variable, slowing the whole machine down for a "gentle cycle" state is a single property change rather than two animation-duration edits that have to be kept in sync by hand.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| --wm-cycle-duration | 3s / 1.5s | How long one full spin-and-shake cycle takes; shared by both animations |
| knob gradient hard stop | 25% → 26% | How crisp the knob edge looks; wider gaps read as a soft glow instead |
| box-shadow offset (feet) | 102px 0 | Horizontal distance between the drawn foot and its shadow-clone |
| drum keyframe steps | 0/50/75/100% | Uneven spacing is what reads as the wash cycle speeding up |
| diagonal stripe size | 30px 100% | Width of the reflective streak crossing the porthole glass |
| prefers-reduced-motion | animation: none | Disables both loops for users who have reduced motion enabled |
Full Source Code
The complete loader is a single self-contained HTML document — three elements, one token block, and two keyframe animations. No build step, no external assets, no JavaScript at all.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/washing-machine-loader.htmlThree elements, one token block, two keyframe animations, and not a single image or line of JavaScript — the whole illusion of a tumbling drum comes down to uneven timing on a rotate() transform.