A smooth, believable loader does more than fill time. It sets a tone for your UI. In this walkthrough you will build a polished “bouncing ball” loading animation with pure CSS, complete with a squish at impact and an animated shadow that sells the motion. By the end, you will have a compact component powered by custom properties that you can theme, scale, and reuse across projects.
Why a Bouncing Ball Loader Matters
Loaders communicate state and buy attention. A spinner works, but a small bit of physics reads better to the eye and feels intentional. With CSS transforms and keyframes, you can craft motion that runs on the compositor, which keeps frames smooth on a wide range of devices. Pure CSS also ships zero JavaScript for this effect, which keeps the DOM simple and the behavior easy to reason about. The pattern you will build here is flexible: change size, color, or rhythm without rewriting markup.
Prerequisites
You do not need a framework or animation library. A single HTML wrapper and a few CSS rules drive the whole interaction. Some familiarity with basic selectors and transforms will help.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The loader uses one wrapper that defines the “stage” and a child element for the ball. The wrapper also exposes an accessible status for assistive tech. Here is the complete markup you will use:
<div class="loader" role="status" aria-live="polite" aria-label="Loading">
<div class="ball" aria-hidden="true"></div>
<span class="sr-only">Loading</span>
</div>
The .loader element is the container that sets size and alignment. The .ball is the animated shape. The .sr-only text gives a fallback label while the visual elements remain purely decorative. You will add a “ground line” and the soft shadow using pseudo-elements to keep the HTML lean.
Step 2: The Basic CSS and Layout
Start with variables for size, color, and timing. The container uses grid to center the ball at the bottom of the stage, and a simple utility class hides the live region from sighted users while keeping it available to screen readers.
/* CSS */
/* CSS */
:root {
--ball-size: 28px;
--ball-color: hsl(212 90% 55%);
--duration: 900ms;
--bounce-height: 96px; /* distance from ground to peak */
--ground-thickness: 2px;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html, body {
height: 100%;
}
body {
display: grid;
place-items: center;
margin: 0;
background: hsl(210 20% 98%);
color: hsl(210 10% 20%);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Loader stage */
.loader {
/* stage height = bounce + ball size, so the ball never clips */
--stage-height: calc(var(--bounce-height) + var(--ball-size));
position: relative;
width: calc(var(--ball-size) * 6);
height: var(--stage-height);
display: grid;
place-items: end center; /* bottom-center alignment for the ball */
isolation: isolate; /* keep pseudo-elements layered relative to this component */
}
Advanced Tip: Expose values with CSS variables so you can theme the loader from the outside. You can attach a data attribute (for example, data-size="large") and override –ball-size and –bounce-height in one place without touching the animation code.
Step 3: Building the Ball and Shadow
Next, draw the ball with a soft highlight and add a ground plane with a matching shadow. The ground and shadow live on the container’s pseudo-elements to keep the markup minimal.
/* CSS */
/* CSS */
/* Ground line */
.loader::before {
content: "";
position: absolute;
left: 10%;
right: 10%;
bottom: 0;
height: var(--ground-thickness);
background: hsl(210 14% 90%);
border-radius: 999px;
}
/* Contact shadow */
.loader::after {
content: "";
position: absolute;
left: 50%;
bottom: calc(var(--ground-thickness) + 1px);
transform: translateX(-50%) scaleX(1) scaleY(1);
transform-origin: 50% 50%;
width: calc(var(--ball-size) * 2);
height: calc(var(--ball-size) * 0.35);
border-radius: 50% / 60%;
background:
radial-gradient(closest-side, hsl(0 0% 0% / 0.25), transparent 65%);
opacity: 0.35;
will-change: transform, opacity;
}
/* Ball */
.ball {
position: absolute;
left: 50%;
bottom: calc(var(--ground-thickness) + 1px);
width: var(--ball-size);
height: var(--ball-size);
transform: translateX(-50%);
transform-origin: 50% 100%; /* squish from the bottom */
border-radius: 50%;
background:
radial-gradient(circle at 35% 30%, hsl(0 0% 100% / 0.9), transparent 45%),
radial-gradient(circle at 50% 55%, hsl(0 0% 100% / 0.15), transparent 60%),
var(--ball-color);
box-shadow:
inset 0 -3px 6px hsl(0 0% 0% / 0.15),
0 1px 0 hsl(0 0% 100% / 0.6);
will-change: transform;
}
/* Optional glossy streak for extra depth */
.ball::after {
content: "";
position: absolute;
top: 12%;
left: 28%;
width: 34%;
height: 34%;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, white, transparent 60%);
opacity: 0.6;
pointer-events: none;
}
How This Works (Code Breakdown)
The ball is a classic circular shape made with border-radius: 50%. If you want a refresher on shape basics, the guide on how to make a circle with CSS covers sizing, backgrounds, and border-radius nuances. A layered radial-gradient adds a highlight and a soft body sheen, while an inset shadow deepens the bottom edge so the sphere reads as round rather than flat.
The loader’s ::before draws a low-contrast ground line. This anchors the motion visually and gives the eye a reference point for impact. The ::after pseudo-element builds a flattened ellipse that sits just above that line. An ellipse sells contact better than a round shadow. If you want to go deeper on oval math, see how to make an ellipse with CSS. The width is about twice the ball’s size; that gives room to scale the shadow during flight and impact.
Positioning uses absolute coordinates relative to the stage, with left: 50% and transform: translateX(-50%) to center the elements. transform-origin on the ball is set to bottom center so scaleY squashes from the ground up during impact, not from the middle. The container dimension acts like a simple rectangle stage that guarantees the ball has room to travel without clipping.
Step 4: Animating the Bounce
Now wire up motion. You will animate the ball’s vertical translation and scale to mimic acceleration and compression, then sync the shadow’s width and opacity to match distance from the ground.
/* CSS */
/* CSS */
/* Ball bounce animation */
@keyframes bounce {
/* Start on the ground; next segment accelerates upward */
0% {
transform: translate(-50%, 0) scaleX(1.05) scaleY(0.95);
animation-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);
}
/* Peak height; slow at the top */
45% {
transform: translate(-50%, calc(-1 * var(--bounce-height))) scaleX(0.98) scaleY(1.02);
animation-timing-function: cubic-bezier(0.65, 0, 0.8, 0.35);
}
/* Back to ground with a squish */
100% {
transform: translate(-50%, 0) scaleX(1.08) scaleY(0.92);
}
}
/* Shadow changes size and fades with altitude */
@keyframes shadow {
0% {
transform: translateX(-50%) scaleX(1) scaleY(1);
opacity: 0.35;
animation-timing-function: cubic-bezier(0.2, 0.6, 0.35, 1);
}
45% {
transform: translateX(-50%) scaleX(0.55) scaleY(0.85);
opacity: 0.18;
animation-timing-function: cubic-bezier(0.65, 0, 0.8, 0.35);
}
100% {
transform: translateX(-50%) scaleX(1) scaleY(1);
opacity: 0.35;
}
}
.ball {
animation: bounce var(--duration) infinite;
}
.loader::after {
animation: shadow var(--duration) infinite;
}
How This Works (Code Breakdown)
The bounce keyframes control transform for three values: translate for vertical motion, and scaleX/scaleY for squish and stretch. The easing changes per segment by setting animation-timing-function at 0% and 45%. The first half uses a curve that starts gently then pushes upward, which looks like gravity doing work. At the top of the arc the curve slows, then the second half accelerates downward.
At impact the ball scales wider (scaleX 1.08) and shorter (scaleY 0.92). The transform-origin at the bottom keeps the squish pinned to the ground so the ball does not appear to sink below the line. The shadow scales and fades as the ball rises. That timed shrink toward 55% of width plus a drop in opacity creates the depth cue that the ball is far from the ground at the apex. The return to full width and opacity at 100% matches the ball hitting the ground again.
Advanced Techniques: Variants, Pausing, and Theming
Small touches make this loader fit more interfaces. You can add a hover pause for debugging, expose a speed control through variables, or create multiple rhythm variants. Here are three upgrades you can apply without changing the HTML.
/* CSS */
/* CSS */
/* 1) Hover to pause (handy while tuning curves) */
.loader:hover .ball,
.loader:hover::after {
animation-play-state: paused;
}
/* 2) Speed variants via data attribute */
.loader[data-speed="slow"] {
--duration: 1300ms;
}
.loader[data-speed="fast"] {
--duration: 650ms;
}
/* 3) Bouncier first impact using a small overshoot */
@keyframes bounce-bouncy {
0% {
transform: translate(-50%, 0) scaleX(1.06) scaleY(0.94);
animation-timing-function: cubic-bezier(0.18, 0.6, 0.3, 1);
}
42% {
transform: translate(-50%, calc(-1 * var(--bounce-height))) scaleX(0.98) scaleY(1.02);
animation-timing-function: cubic-bezier(0.7, 0, 0.8, 0.3);
}
70% {
transform: translate(-50%, calc(-1 * var(--bounce-height) * 0.35)) scaleX(1.04) scaleY(0.96);
animation-timing-function: cubic-bezier(0.6, 0.1, 0.3, 1);
}
100% {
transform: translate(-50%, 0) scaleX(1.06) scaleY(0.94);
}
}
/* Toggle the variant by adding a class */
.loader.is-bouncy .ball { animation-name: bounce-bouncy; }
.loader.is-bouncy::after { animation-name: shadow; }
/* Theming by switching a single variable */
.loader[data-theme="lime"] { --ball-color: hsl(100 80% 45%); }
.loader[data-theme="coral"] { --ball-color: hsl(15 90% 60%); }
.loader[data-theme="violet"] { --ball-color: hsl(270 70% 60%); }
/* Motion preference: stop the animation and show a static cue */
@media (prefers-reduced-motion: reduce) {
.ball,
.loader::after {
animation: none;
}
.ball {
transform: translate(-50%, 0) scaleX(1) scaleY(1);
}
.loader::after {
transform: translateX(-50%) scaleX(1) scaleY(1);
opacity: 0.3;
}
}
The is-bouncy variant adds a mid-air rebound to suggest a softer ball. A data-speed attribute switches timing without touching CSS files; you can set it from the server or a CMS. The theme examples shift color by flipping a single variable, which keeps the gradient highlights and shadows in sync with your palette. The prefers-reduced-motion query turns off animation to respect user settings.
Accessibility & Performance
Motion should communicate, not distract. This component includes hooks for assistive tech and respects reduced-motion preferences. It also runs on properties that modern browsers handle on the compositor, which keeps frames steady.
Accessibility
The loader wrapper uses role=”status” so screen readers announce its presence. aria-live=”polite” avoids interrupting ongoing speech. aria-label=”Loading” sets a clear state message. Because the ball and shadow are visual only, aria-hidden=”true” on .ball keeps it silent. The .sr-only text gives a non-visual label if you prefer that approach. The prefers-reduced-motion media query stops the animation for users who request less motion. If the loader masks content for more than a brief moment, pair it with text that explains progress or an ETA.
Performance
The animation relies on transform and opacity. Those properties avoid layout thrash and paint-heavy work, so they tend to animate smoothly on a wide range of hardware. will-change hints that the element will animate, which helps the browser prepare a layer. Keep the DOM small; drawing the ground and shadow as pseudo-elements avoids extra nodes. Skip blur-heavy box-shadows or filters on animated elements since those can add overhead. If you place many loaders on a page, reduce the duration variance or stagger starts so they do not spike the main thread at the same time.
Sharpen Your Motion Toolkit
You built a compact, themable “bouncing ball” loader with convincing squash-and-stretch physics, a responsive shadow, and accessible markup. You now have a pattern you can re-skin, speed up, or tone down to match any product surface. Use this as a base to script multi-ball sequences, add horizontal drift, or craft a full set of branded loaders for your design system.