Aduok Code

Desert Sand Ball Loader with Pure CSS

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.

What you will learn

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.

01
Part One
Design Tokens

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.

CSS
: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));
}
Why fluid font-size on :root?

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.

02
Part Two
HTML Structure

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.

HTML
<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>
ElementRoleAnimated
dune-spinnerPositioned container, sets sizeNo
dune-spinner-ring-outerOuter groove ring — box-shadow onlyNo
dune-spinner-ring-innerInner groove ring — box-shadow onlyNo
dune-spinner-veilCo-rotating conic mask — hides lower arcYes — trackCover
dune-spinner-beadThe orbiting ball pivotYes — orbit
dune-spinner-bead-wrapClips the scrolling sand textureNo
dune-spinner-bead-dropElongated drop shadow beneath beadYes — counterSpinDrop
dune-spinner-bead-shadeInner rim shadow, counter-rotatesYes — counterSpin
dune-spinner-bead-flanksSquashed side darkening ellipseNo
03
Part Three
The Track Rings

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.

CSS
/* 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;
}
The attribute selector trick

[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.

04
Part Four
The Veil

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.

CSS
.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.

05
Part Five
The Bead

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.

CSS
.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));     }
}
Why does the radius change at 50%?

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.

06
Part Six
Sand Texture

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.

CSS
.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%); }
}
07
Part Seven
Bead Shadows

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.

CSS
/* 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.

08
Part Eight
Keyframe Summary

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.

CSS
@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 / PropertyDefaultEffect
--duration-orbit3sFull orbit cycle speed. Lower = faster spin
--duration-texture0.28sSand scroll speed. Lower = faster rolling feel
--spinner-size16emOverall diameter. Scales with root font-size
--bead-size2.5emBall diameter. Keep below ~20% of spinner-size
--orbit-radius-6.5emDistance from center to ball. Increase = wider orbit
--orbit-radius-mid-6emOrbit radius at 180°. Difference creates the bounce
--color-bghsl(28,60%,18%)Must match the veil conic-gradient exactly
--color-sand-100-700Sand scaleFour-stop palette. Adjust for any terrain theme
conic-gradient angles210-270degWidth of the veil mask. Wider = more arc hidden
counterSpinDrop from20degTilt 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.

HTML — dune-spinner.html (complete)
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/dune-spinner.html

Every pixel of depth in DuneSpinner is pure CSS — no images, no JS, no tricks that break in dark mode.

Want to see it in action?Watch the full build on YouTube