Introduction
In this article we build DuneSpinner — a fully animated desert-themed orbiting ball loader from scratch. No JavaScript, no images, no external libraries. Just HTML, CSS custom properties, conic gradients, and a set of coordinated keyframe animations. The result is a sand-textured ball that orbits a carved sandstone ring, appearing to dip behind the track at the bottom and re-emerge at the top.
The loader is built from five CSS layers stacked inside one .dune-spinner container: two track rings, a rotating veil that masks the lower arc, and a bead with four sub-layers for texture and depth. Every magic number — colors, sizes, timing — lives in CSS custom properties at :root so the whole component is trivially themeable.
How to fake a 3-D carved ring with only box-shadow — how to use a conic-gradient and a co-rotating element to mask part of an orbit — how to build a scrolling CSS-only sand texture using layered radial-gradients — how to counter-rotate a shadow so it stays fixed while its parent orbits — how to structure design tokens with CSS custom properties for a fully themeable component.
How the Illusion Works
The "ball disappears behind the track" effect is the most important trick in this loader. It is created by a single element called the veil — a full-size circle with a conic-gradient that matches the background color. The veil rotates in sync with the bead, always painting over the bottom arc of the ring. When the bead passes through that arc, the veil covers it, making the ball appear to go behind the track.
The veil is not a mask — it is just a co-rotating circle painted the same color as the background. Simple, reliable, and zero JavaScript.
This is why --color-bg must be a single flat color and must match the veil's conic-gradient exactly. Any mismatch — even a semi-transparent background — will break the illusion and expose the veil edge.
Step 1 — CSS Custom Properties
All values are declared as custom properties on :root. This makes the component self-documenting and trivial to re-theme. The tokens are split into four groups: palette, geometry, and motion.
:root {
/* palette */
--color-bg: hsl(28, 60%, 18%); /* dark desert night */
--color-sand-100: hsl(46, 88%, 90%); /* dune highlight */
--color-sand-300: hsl(40, 70%, 70%); /* mid sand */
--color-sand-500: hsl(28, 60%, 52%); /* deep sand */
--color-sand-700: hsl(20, 52%, 38%); /* shadow sand */
--color-shadow: hsla(20, 60%, 8%, 0.22);
--color-shadow-hard: hsla(20, 60%, 8%, 0.40);
--color-shine: hsla(38, 70%, 55%, 0.22);
/* geometry */
--spinner-size: 16em;
--bead-size: 2.5em;
--orbit-radius: -6.5em;
--orbit-radius-mid: -6em;
/* motion */
--duration-orbit: 3s;
--duration-texture: 0.28s;
/* fluid type scale */
font-size: calc(16px + (20 - 16) * (100vw - 320px) / (1280 - 320));
}All sizes use em units, so they scale relative to the root font-size. The calc() formula linearly interpolates between 16px at 320px viewport and 20px at 1280px — meaning the spinner smoothly grows and shrinks with the viewport without a single media query.
Step 2 — The Markup
The entire component is seven divs. The naming convention is flat hyphenated BEM — dune-spinner for the container, dune-spinner-{part} for children. No double underscores, no extra nesting.
<div class="dune-spinner">
<div class="dune-spinner-ring-outer"></div>
<div class="dune-spinner-ring-inner"></div>
<div class="dune-spinner-veil"></div>
<div class="dune-spinner-bead">
<div class="dune-spinner-bead-wrap"></div>
<div class="dune-spinner-bead-drop"></div>
<div class="dune-spinner-bead-shade"></div>
<div class="dune-spinner-bead-flanks"></div>
</div>
</div>| Element | Role | Animated |
|---|---|---|
| dune-spinner | Positioned container, sets size | No |
| dune-spinner-ring-outer | Outer groove ring — box-shadow only | No |
| dune-spinner-ring-inner | Inner groove ring — box-shadow only | No |
| dune-spinner-veil | Co-rotating conic mask — hides lower arc | Yes — trackCover |
| dune-spinner-bead | The orbiting ball pivot | Yes — orbit |
| dune-spinner-bead-wrap | Clips the scrolling sand texture | No |
| dune-spinner-bead-drop | Elongated drop shadow beneath bead | Yes — counterSpinDrop |
| dune-spinner-bead-shade | Inner rim shadow, counter-rotates | Yes — counterSpin |
| dune-spinner-bead-flanks | Squashed side darkening ellipse | No |
Step 3 — Faking a Carved 3-D Ring
Neither ring has a background color. They are invisible circles — their entire appearance comes from box-shadow. Four shadows per ring: a bottom-outset dark shadow, a top-outset warm shine, and the same two repeated as inset. The result is a convex-then-concave cross-section that reads as a carved groove.
/* Shared attribute selector — applies border-radius + position to all children */
[class^="dune-spinner"] {
border-radius: 50%;
position: absolute;
}
.dune-spinner {
position: relative;
width: var(--spinner-size);
height: var(--spinner-size);
}
.dune-spinner-ring-outer {
top: 0.75em; left: 0.75em;
width: calc(100% - 1.5em);
height: calc(100% - 1.5em);
box-shadow:
/* outset */
0 -0.45em 0.375em hsla(20, 60%, 5%, 0.22),
0 0.5em 0.75em hsla(20, 60%, 5%, 0.22) inset,
/* inset */
0 0.25em 0.5em var(--color-shine),
0 -0.5em 0.75em var(--color-shine) inset;
}
.dune-spinner-ring-inner {
top: 2.375em; left: 2.375em;
width: calc(100% - 4.75em);
height: calc(100% - 4.75em);
box-shadow:
0 -0.25em 0.5em var(--color-shine),
0 0.5em 0.75em var(--color-shine) inset,
0 0.5em 0.375em hsla(20, 60%, 5%, 0.25),
0 -0.5em 0.75em hsla(20, 60%, 5%, 0.22) inset;
}[class^="dune-spinner"] matches every element whose class starts with "dune-spinner". This sets border-radius: 50% and position: absolute on all seven children in one rule, eliminating repetition without any CSS preprocessor.
Step 4 — The Co-Rotating Mask
The veil is a conic-gradient circle that fills the entire spinner. Its gradient transitions from a solid background color to transparent across a 60-degree arc — 210deg to 270deg. That arc sits at the bottom of the circle when the animation starts, exactly covering the lower track section.
.dune-spinner-veil {
inset: 0;
background: conic-gradient(
var(--color-bg) 210deg,
transparent 270deg
);
animation: trackCover var(--duration-orbit) linear infinite;
}
@keyframes trackCover {
to { transform: rotate(360deg); }
}The trackCover keyframe only needs a to rule because the browser interpolates from the element's initial state (0deg) automatically. One-line keyframe, full effect.
Step 5 — Orbiting the Ball
The bead is centered inside the spinner, then pushed outward by translateY(var(--orbit-radius)). Applying rotate() before translateY() means the translation direction changes with the rotation — this is what makes the ball orbit the ring rather than just translate off in one direction.
.dune-spinner-bead {
width: var(--bead-size);
height: var(--bead-size);
top: calc(50% - var(--bead-size) / 2);
left: calc(50% - var(--bead-size) / 2);
transform: rotate(0deg) translateY(var(--orbit-radius));
animation: orbit var(--duration-orbit) linear infinite;
}
@keyframes orbit {
0% { transform: rotate(0deg) translateY(var(--orbit-radius)); }
50% { transform: rotate(180deg) translateY(var(--orbit-radius-mid)); }
100% { transform: rotate(360deg) translateY(var(--orbit-radius)); }
}At 180° the bead is at the bottom of the ring — exactly where gravity would push it closest to center. Shrinking the radius by 0.5em at the midpoint creates a subtle elliptical bounce that makes the orbit feel physical rather than mechanical.
Step 6 — CSS-Only Sand Surface
The sand texture is a ::before pseudo-element twice as wide as the bead, built from six layered CSS gradients. It scrolls horizontally via translateX(50%) — exactly one tile width — so it loops seamlessly. No image file, no external resource.
The .dune-spinner-bead-wrap parent has overflow: hidden and border-radius: 50% (inherited from the attribute selector), so the scrolling texture is clipped to the ball shape automatically.
.dune-spinner-bead-wrap {
inset: 0;
overflow: hidden;
transform: translateZ(0); /* promote to compositor layer */
}
.dune-spinner-bead-wrap::before {
content: "";
display: block;
position: absolute;
top: 0; right: 0;
width: 200%; height: 100%;
background:
/* dune highlights */
radial-gradient(ellipse 55% 40% at 25% 32%, var(--color-sand-100) 0%, transparent 52%),
radial-gradient(ellipse 40% 28% at 70% 48%, hsl(42, 78%, 82%) 0%, transparent 50%),
/* mid bumps */
radial-gradient(circle at 50% 18%, hsl(40, 72%, 80%) 0%, transparent 40%),
radial-gradient(circle at 18% 68%, hsl(36, 65%, 72%) 0%, transparent 38%),
radial-gradient(circle at 80% 74%, hsl(34, 68%, 68%) 0%, transparent 40%),
/* base ramp */
linear-gradient(148deg,
var(--color-sand-100) 0%,
var(--color-sand-300) 32%,
var(--color-sand-500) 68%,
var(--color-sand-700) 100%
);
filter: brightness(1.05) saturate(1.1);
animation: scrollTexture var(--duration-texture) linear infinite;
}
@keyframes scrollTexture {
to { transform: translateX(50%); }
}Step 7 — Three Shadow Layers
Three elements give the bead its spherical depth. The inner rim shadow counter-rotates to stay visually fixed as the bead orbits. The drop shadow is an elongated teardrop below the bead. The flanks element is a vertically squashed ellipse that darkens the sides.
/* Inner rim — counter-rotates so shading stays fixed */
.dune-spinner-bead-shade {
inset: 0;
box-shadow:
0 0.10em 0.20em var(--color-shadow-hard),
inset 0 0 0.20em hsla(20, 60%, 8%, 0.15),
inset 0 -1em 0.50em hsla(20, 60%, 8%, 0.25);
animation: counterSpin var(--duration-orbit) linear infinite;
}
/* Drop shadow — elongated teardrop beneath bead */
.dune-spinner-bead-drop {
top: 50%; left: 0;
width: 100%; height: 250%;
border-radius: 0 0 50% 50% / 0 0 100% 100%;
background: linear-gradient(var(--color-shadow), transparent);
filter: blur(2px);
transform: rotate(20deg);
transform-origin: 50% 0;
z-index: -2;
animation: counterSpinDrop var(--duration-orbit) linear infinite;
}
/* Flanks — squashed ellipse for ambient occlusion on sides */
.dune-spinner-bead-flanks {
inset: 0;
background: hsla(20, 60%, 8%, 0.13);
filter: blur(2px);
transform: scale(0.75, 1.1);
z-index: -1;
}
@keyframes counterSpin {
to { transform: rotate(-360deg); }
}
@keyframes counterSpinDrop {
from { transform: rotate(20deg); }
to { transform: rotate(-340deg); }
}Counter-rotating the inner shadow is the single detail that separates a flat circle from a convincing sphere — without it the shading spins with the ball and the depth illusion collapses.
Step 8 — All Five Keyframes
Here are all five keyframes collected in one place for reference. Notice that counterSpin, scrollTexture, and trackCover only define a to rule — the browser interpolates from the element's current state for free.
@keyframes orbit {
0% { transform: rotate(0deg) translateY(var(--orbit-radius)); }
50% { transform: rotate(180deg) translateY(var(--orbit-radius-mid)); }
100% { transform: rotate(360deg) translateY(var(--orbit-radius)); }
}
@keyframes trackCover {
to { transform: rotate(360deg); }
}
@keyframes counterSpin {
to { transform: rotate(-360deg); }
}
@keyframes counterSpinDrop {
from { transform: rotate(20deg); }
to { transform: rotate(-340deg); }
}
@keyframes scrollTexture {
to { transform: translateX(50%); }
}Tuning Reference
| Token / Property | Default | Effect |
|---|---|---|
| --duration-orbit | 3s | Full orbit cycle speed. Lower = faster spin |
| --duration-texture | 0.28s | Sand scroll speed. Lower = faster rolling feel |
| --spinner-size | 16em | Overall diameter. Scales with root font-size |
| --bead-size | 2.5em | Ball diameter. Keep below ~20% of spinner-size |
| --orbit-radius | -6.5em | Distance from center to ball. Increase = wider orbit |
| --orbit-radius-mid | -6em | Orbit radius at 180°. Difference creates the bounce |
| --color-bg | hsl(28,60%,18%) | Must match the veil conic-gradient exactly |
| --color-sand-100-700 | Sand scale | Four-stop palette. Adjust for any terrain theme |
| conic-gradient angles | 210-270deg | Width of the veil mask. Wider = more arc hidden |
| counterSpinDrop from | 20deg | Tilt of the drop shadow. Change for different light angle |
Full Source Code
Save the following as dune-spinner.html and open in any modern browser. Zero dependencies, zero build step.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/dune-spinner.htmlEvery pixel of depth in DuneSpinner is pure CSS — no images, no JS, no tricks that break in dark mode.