Aduok Code

Bouncing Dots Loader with Pure HTML & CSS

Introduction

In this article we build BounceLoader — a three-dot loading indicator with physics-informed squash-and-stretch, staggered timing, and a living shadow beneath each dot — built entirely from CSS keyframes and custom properties. When rendered, three dots bounce in sequence: each squashes flat on landing, stretches tall at the apex, and casts a shadow that shrinks and fades as the dot rises. No JavaScript, no SVG animation libraries, no canvas — just HTML, CSS custom properties, and two @keyframes blocks.

Every visual element — the dots, the gradient fill, the glow, the shadows — is built from plain div elements. The animation runs as an alternating infinite cycle across two separate @keyframes blocks. The entire component is re-themeable by editing a single :root block.

What you will learn

How to fake physics with CSS keyframes using squash-and-stretch — how to use animation-direction: alternate to avoid writing both halves of a bounce — how to sync a shadow element to a dot using matched animation-delay — how to build a glowing dot using linear-gradient and box-shadow — how to position three independent actors inside a single container using absolute positioning and percentage left offsets — and how to control every visual variable from a single :root token block.

01
Part One
Design Tokens

Step 1 — CSS Custom Properties

All sizing, colour, and timing values live in :root as custom properties. Changing --cycle alone rescales the entire animation speed. Swapping --dot-base and --dot-highlight recolours every dot and glow simultaneously.

CSS
:root {
  /* layout */
  --loader-width:  200px;
  --loader-height:  60px;
  --dot-size:       20px;

  /* palette */
  --dot-base:       #ffffff;       /* dot fill base stop */
  --dot-highlight:  #ffffff;       /* dot fill highlight stop */
  --bg-start:       #000000;       /* radial gradient inner */
  --bg-end:         #000000;       /* radial gradient outer */
  --shadow-color:   rgba(255,255,255,0.35); /* shadow disc colour */
  --shadow-blur:    1px;           /* shadow blur radius */

  /* timing */
  --cycle: 0.5s;   /* half-cycle — one direction of the alternate bounce */
  --ease:  ease;   /* easing applied to both bounce and shadow */
}
02
Part Two
HTML Structure

Step 2 — The Markup

The loader contains six child elements: three .loader__dot divs and three .loader__shadow divs. Each dot is paired with a shadow at the matching index. The shadow elements are rendered after the dots in the DOM so they can be layered behind via z-index: -1 without a wrapper.

HTML
<div class="loader" role="status" aria-label="Loading">

  <!-- the three bouncing dots -->
  <div class="loader__dot"></div>
  <div class="loader__dot"></div>
  <div class="loader__dot"></div>

  <!-- one shadow per dot, matched by nth-child index -->
  <div class="loader__shadow"></div>
  <div class="loader__shadow"></div>
  <div class="loader__shadow"></div>

</div>
ElementRoleAnimated
.loaderPositioning container — 200×60px, position: relativeNo
.loader__dotCircular dot — bounces between top: 60px and top: 0Yes — bounce-dot keyframe
.loader__dot (shape)Squashes to a wide flat oval on landing, round at apexYes — scaleX + border-radius in keyframe
.loader__shadowFlat ellipse beneath each dot — shrinks as dot risesYes — bounce-shadow keyframe
.loader__shadow (opacity)Fades from 0.7 at ground to 0.4 at full heightYes — opacity in keyframe
nth-child delaysDots 2 and 3 are delayed 0.2s and 0.3s for the staggerYes — animation-delay
03
Part Three
The Stage & Positioning

Step 3 — Positioning the Actors

The .loader container is position: relative and has an explicit 200×60px footprint. Every dot and shadow inside it uses position: absolute, which removes them from normal flow and lets their top and left values be controlled directly by CSS and by the keyframe. The three dots are placed at left: 15%, left: 45%, and right: 15% — evenly dividing the container into thirds.

The loader container is a fixed-size stage. Every dot enters it already in position — the animation only moves them vertically. Horizontal placement is set once with left or right percentages and never touched again.

CSS
.loader {
  width:    var(--loader-width);    /* 200px */
  height:   var(--loader-height);   /* 60px  */
  position: relative;               /* coordinate space for absolute children */
  z-index:  1;                      /* stacking context so shadow z-index: -1 works */
}

.loader__dot {
  position: absolute;
  left: 15%;   /* dot 1 — left third */
}
.loader__dot:nth-child(2) { left: 45%; }        /* dot 2 — centre */
.loader__dot:nth-child(3) { left: auto; right: 15%; } /* dot 3 — right third */
04
Part Four
Bounce Animation

Step 4 — The Bounce Keyframe

The bounce-dot keyframe only defines the downward half of the bounce — from apex to floor. The animation-direction: alternate property plays it forward (down) then backward (up) automatically, so you write one direction and get both for free. At 0% the dot sits at top: 60px — floor level. At 100% it sits at top: 0 — the ceiling of the container.

CSS
@keyframes bounce-dot {
  /* floor: squashed flat, wide, at the bottom of the container */
  0% {
    top:           60px;
    height:        5px;
    border-radius: 50px 50px 25px 25px;
    transform:     scaleX(1.7);
  }

  /* mid-air: regains circular shape quickly */
  40% {
    height:        20px;
    border-radius: 50%;
    transform:     scaleX(1);
  }

  /* apex: full height, fully round, at top of container */
  100% {
    top: 0%;
  }
}

.loader__dot {
  animation: bounce-dot var(--cycle) alternate infinite var(--ease);
}
Why alternate instead of writing a full loop?

animation-direction: alternate runs the keyframe forward on odd iterations and backward on even iterations. This means the 0%→100% drop automatically becomes a 100%→0% rise on the return trip — including the squash shape at the bottom. Writing a mirrored 100%→0% block would be redundant and harder to maintain. Alternate is the idiomatic CSS way to express a ping-pong motion.

05
Part Five
Squash & Stretch

Step 5 — Squash & Stretch Physics

Squash and stretch is one of the twelve principles of animation. On landing, a bouncing ball compresses vertically and spreads horizontally. On rising, it stretches tall and narrow. The bounce-dot keyframe approximates both using three CSS properties in tandem: height, border-radius, and transform: scaleX.

At the floor the dot is 5px tall, 1.7× wider than normal, and has a flat-bottomed oval border-radius. By 40% of the rise it has already snapped back to a perfect circle — matching how a real ball decompresses fast off a hard surface.

CSS
/* squash: landing shape */
0% {
  height:        5px;                       /* compressed vertically */
  border-radius: 50px 50px 25px 25px;       /* flat bottom, round top */
  transform:     scaleX(1.7);              /* spread horizontally */
}

/* stretch: mid-air shape recovers fast */
40% {
  height:        20px;   /* = var(--dot-size), back to full height */
  border-radius: 50%;    /* perfect circle */
  transform:     scaleX(1);
}

/* apex: no override needed — values from 40% carry forward */
100% {
  top: 0%;
}
06
Part Six
Dynamic Shadows

Step 6 — The Living Shadow

Each dot has a matching .loader__shadow element positioned at top: 62px — just below the floor. The shadow runs its own keyframe, bounce-shadow, on the exact same --cycle duration. It stays pinned at the floor and only animates scaleX and opacity. As the dot rises, the shadow shrinks from scaleX(1.5) to scaleX(0.2) and fades from opacity 0.7 to 0.4 — simulating the natural contact shadow behaviour of a physical object.

CSS
.loader__shadow {
  width:      var(--dot-size);   /* same width as the dot above it */
  height:     4px;
  border-radius: 50%;
  background: var(--shadow-color);
  position:   absolute;
  top:        62px;              /* floor + 2px gap */
  z-index:    -1;                /* renders behind the dot */
  filter:     blur(var(--shadow-blur));
  animation:  bounce-shadow var(--cycle) alternate infinite var(--ease);
}

@keyframes bounce-shadow {
  /* floor: shadow is large and fairly opaque — dot is close */
  0% {
    transform: scaleX(1.5);
  }

  /* mid-air: shadow shrinks a little, starts to fade */
  40% {
    transform: scaleX(1);
    opacity:   .7;
  }

  /* apex: shadow is tiny and nearly invisible — dot is far away */
  100% {
    transform: scaleX(.2);
    opacity:   .4;
  }
}
07
Part Seven
Staggered Delays

Step 7 — Staggering the Three Dots

All three dots share the identical bounce-dot keyframe. The cascade effect comes entirely from animation-delay. Dot 1 starts immediately at 0s. Dot 2 starts at 0.2s. Dot 3 starts at 0.3s. Each shadow carries the same delay as its paired dot, so the shadow always tracks in sync with the dot above it — even though they are separate elements with no JavaScript coordination.

CSS
/* dot 1: no delay — leads the sequence */
.loader__dot            { animation-delay: 0s;   }

/* dot 2: 0.2s behind dot 1 */
.loader__dot:nth-child(2)     { animation-delay: 0.2s; }

/* dot 3: 0.3s behind dot 1 */
.loader__dot:nth-child(3)     { animation-delay: 0.3s; }

/* shadows — must match their paired dot's delay exactly */
.loader__shadow               { animation-delay: 0s;   }
.loader__shadow:nth-child(4)  { animation-delay: 0.2s; } /* paired with dot 2 */
.loader__shadow:nth-child(5)  { animation-delay: 0.3s; } /* paired with dot 3 */
Why nth-child(4) and nth-child(5) for the shadows?

All six elements — three dots then three shadows — are direct children of .loader. The dots are children 1, 2, 3. The shadows are children 4, 5, 6. nth-child counts all siblings regardless of class, so .loader__shadow:nth-child(4) selects the first shadow (the fourth child overall). This avoids adding wrapper divs or extra classes just for delay targeting.

Tuning Reference

Token / PropertyDefaultEffect
--cycle0.5sHalf-cycle duration. Halve for a snappier bounce, double for a lazy one
--dot-size20pxDiameter of each dot and width of each shadow
--dot-base / --dot-highlight#ffffffGradient stops for the dot fill. Change to retheme all three dots
--shadow-colorrgba(255,255,255,0.35)Colour and base opacity of the shadow discs
--shadow-blur1pxBlur radius on the shadow. Higher = softer, more diffuse shadow
scaleX(1.7) at 0%1.7Squash width at landing. Higher = more exaggerated squash
height: 5px at 0%5pxSquash height at landing. Lower = flatter pancake on impact
40% keyframe stop40%How quickly the dot snaps back to round after landing. Lower = snappier recovery
animation-delay dot 20.2sOffset of the centre dot. Increase to widen the stagger gap
animation-delay dot 30.3sOffset of the right dot. Must always be ≥ dot 2 delay for left-to-right cascade
top: 62px on shadow62pxFloor position of shadows. Should match the 0% top value of the dot plus the dot height

Full Source Code

Save the following as bouncing-loader.html and open in any modern browser. Zero dependencies, zero build step, zero JavaScript.

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

Every effect in BounceLoader — the squash on landing, the stretch at the apex, the shrinking shadow, the staggered cascade — is pure CSS keyframes. Not a single line of JavaScript touches the animation itself.

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