9 Loading Screen Designs — Pretty CSS Loaders
A loading screen design is the first thing your site says while the user waits. A pastel ring or a circle filling with water makes that wait feel intentional —
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Candy ring spin
- 02 Gooey dots
- 03 Morphing blob
- 04 Squish ball
- 05 Orbit dots
- 06 Liquid fill circle
- 07 Count-up number
- 08 Cube stack
- 09 Curtain reveal
The nine follow the order you'd actually pick them in, not popularity. You start with the safe first choice, the candy ring (01), and the familiar dots (02), move into personality with the blob (03) and the ball (04), and then give the wait meaning with the orbit (05) and the liquid fill (06). The count-up (07) and cube stack (08) show progress, and the curtain (09) stages the moment loading ends.
01Candy ring spin
A conic-gradient ring blending three pastel colors gets carved into a donut with mask and spins once a second. A wrapper around it breathes to 1.12× on its own slower, 2-second cycle, so the ring feels alive instead of merely rotating. It reads like a piece of candy on a dark first screen, which makes it a good flagship loader for dark-themed apps.
.cr-ring {
width: 148px;
height: 148px;
border-radius: 50%;
background: conic-gradient(from 0deg, $color, $subject-lilac, $subject-sky, $color);
mask: radial-gradient(farthest-side, #0000 calc(100% - 22px), #000 calc(100% - 21px));
}
.cr-wrap.is-demo {
.cr-ring { animation: cr-spin $duration linear infinite; }
animation: cr-breathe 2s $easing infinite;
}
@keyframes cr-spin { to { transform: rotate(360deg); } }
@keyframes cr-breathe {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.12); }
}
02Gooey dots
Only the middle dot moves — it slides left and right to visit its two stationary siblings, stretching into a blob on contact before snapping back to center. The first version used the classic blur plus contrast filter trick, but on a yellow background it left a red rim around every dot, so the demo now boosts only the alpha channel with an SVG feColorMatrix. Same motion, zero color artifacts.
<svg width="0" height="0" style="position:absolute">
<defs>
<filter id="goo-filter">
<feGaussianBlur in="SourceGraphic" stdDeviation="8" result="blur"/>
<feColorMatrix in="blur" mode="matrix"
values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7"/>
</filter>
</defs>
</svg>
<div class="goo">
<span class="goo-dot"></span>
<span class="goo-dot gd-b"></span>
<span class="goo-dot"></span>
</div>
.goo {
display: flex;
align-items: center;
padding: $sp-6 $sp-8;
filter: url(#goo-filter);
}
.gd-b { animation: goo-slide 2s $ease-spring infinite; }
@keyframes goo-slide {
0%, 50%, 100% { transform: translateX(0); }
25% { transform: translateX(-68px); }
75% { transform: translateX(68px); }
}
03Morphing blob
A cushion-shaped blob squirms without pause by stepping through eight border-radius values while rotating 90 degrees between each. The four radius pairs return to their starting values exactly on the 2-second loop, so the seam is invisible. The static base keeps a blob shape too, so it still looks right when animation is off.
.mb-blob {
width: 112px;
height: 112px;
border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%;
background: $color;
}
.mb-blob.is-demo { animation: mb-morph $duration linear infinite; }
@keyframes mb-morph {
0% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(0deg) scale(1); }
25% { border-radius: 45% 55% 60% 40% / 52% 42% 58% 48%; transform: rotate(90deg) scale(1.06); }
50% { border-radius: 57% 43% 40% 60% / 55% 47% 53% 45%; transform: rotate(180deg) scale(.96); }
75% { border-radius: 42% 58% 57% 43% / 48% 55% 45% 52%; transform: rotate(270deg) scale(1.04); }
100% { border-radius: 58% 42% 43% 57% / 45% 58% 42% 55%; transform: rotate(360deg) scale(1); }
}
04Squish ball
The ball drops, splats flat to .55 height on impact, holds a beat, then rebounds on its own elasticity. transform-origin sits at the bottom center so the ball's underside stays glued to the floor during the squash, and a shadow ellipse shrinks and fades while the ball is high. The fall uses an accelerating cubic-bezier(.45, 0, .8, .6) for a sense of gravity.
.sq-ball {
position: absolute;
left: 50%;
bottom: 2px;
width: 56px;
height: 56px;
margin-left: -28px;
border-radius: 50%;
background: $color;
transform-origin: 50% 100%;
}
.sq-ball.is-demo { animation: sq-bounce 1s cubic-bezier(.45, 0, .8, .6) infinite; }
@keyframes sq-bounce {
0% { transform: translateY(-98px) scale(1, 1); }
55% { transform: translateY(0) scale(1, 1); }
68% { transform: translateY(0) scale(1.28, .55); }
80% { transform: translateY(0) scale(.92, 1.08); }
100% { transform: translateY(-98px) scale(1, 1); }
}
05Orbit dots
Three differently sized dots share one center and orbit on radii of 36, 52 and 68px. The smaller the dot, the faster the angular speed (1s versus 2s), which gives a planetary sense of depth, and the three start 120 degrees apart so they never bunch up. What rotates is each dot itself, not a big arm box — the reason is in the trap section below.
.od-s { // small dot: radius 52
top: 26px;
left: calc(50% - 6px);
width: 12px;
height: 12px;
transform-origin: 50% 58px; // distance from dot to system center
animation: ob-spin-a 1s linear infinite;
}
@keyframes ob-spin-a {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
06Liquid fill circle
Water rises inside a rounded ring while the surface itself comes from a big rounded square (radius 36%) rotating in place. The rise moves only the translate property so it never fights the rotation on transform, and the square's corners sweep a ripple across the surface as it turns. The 2-second fill-and-drain loop keeps long waits from feeling stuck.
.lq-tube {
position: relative;
width: 150px;
height: 150px;
border: 4px solid $subject-ink;
border-radius: 50%;
overflow: hidden;
}
.lq-water {
position: absolute;
top: 100%;
left: calc(50% - 55px);
width: 110px;
height: 110px;
border-radius: 36%;
background: $color;
animation: lq-wave 2s linear infinite, lq-rise $duration $ease-spring infinite;
}
@keyframes lq-wave { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes lq-rise {
0% { translate: 0 0; }
50% { translate: 0 -60px; }
100% { translate: 0 0; }
}
07Count-up number
With zero JavaScript, an integer variable --n registered through @property climbs from 0 to 100 and prints itself via counter(). A thin conic-gradient ring reads the same variable, so the digits and the gauge can never drift apart. The number itself is aria-hidden — it's decoration — while a visually hidden label carries role="status", so assistive tech announces the progress from that label instead of reading digits one by one.
@property --n {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.cu-num {
counter-reset: n var(--n);
}
.cu-num::after { content: counter(n) "%"; }
.cu-box.is-demo { animation: cu-count $duration linear infinite; }
@keyframes cu-count {
from { --n: 0; }
to { --n: 100; }
}
08Cube stack
Four squares pop into their cells in grid reading order — top-left, top-right, bottom-left, bottom-right — at 120ms intervals, then a beat later all four settle together. The entrance (inner cube scale) and the landing (outer cell translateY) are split across two nested elements so one transform never overwrites the other. It maps naturally onto a screen that loads one module at a time.
.cs-cell { animation: cs-settle $duration $ease-pop infinite; }
.cs-cube { animation: cs-pop $duration $ease-pop infinite backwards; }
.cs-cell:nth-child(2) .cs-cube { animation-delay: 120ms; }
.cs-cell:nth-child(3) .cs-cube { animation-delay: 240ms; }
.cs-cell:nth-child(4) .cs-cube { animation-delay: 360ms; }
@keyframes cs-pop {
0% { transform: scale(0); opacity: 0; }
16% { transform: scale(1.18); opacity: 1; }
22%, 68% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
@keyframes cs-settle {
0%, 60% { transform: translateY(0); }
70% { transform: translateY(8px); }
80%, 100% { transform: translateY(0); }
}
09Curtain reveal
This one stages the moment loading ends. Two curtain panels push outward to uncover the content placeholder behind them (title bar, text lines, thumbnail), stay open for a beat, then draw closed and repeat. In production you open the curtains when loading finishes and remove the class, and it doubles as a page-transition cover.
.ct-left { left: 0; background: $color; }
.ct-right { right: 0; background: $subject-pink; }
.ct-scene.is-demo .ct-left { animation: ct-open-left $duration $ease-spring infinite; }
.ct-scene.is-demo .ct-right { animation: ct-open-right $duration $ease-spring infinite; }
@keyframes ct-open-left {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(-101%); }
94%, 100% { transform: translateX(0); }
}
@keyframes ct-open-right {
0%, 12% { transform: translateX(0); }
38%, 72% { transform: translateX(101%); }
94%, 100% { transform: translateX(0); }
}
Where it breaks — a rotating box overflows the phone screen
The first version of item 05 rotated a big square arm holding the dots, the way most orbit loaders are built. It looked fine on desktop, then the 320px phone-width check flagged a vertical overflow: the dots are tiny, but a 168px square rotated to 45 degrees has a bounding box the size of its diagonal (about 237px), and scroll-height math counts that inflated box. Measured directly, body reported 218px against a 200px viewport — and neither overflow: hidden nor clip stops that count. The fix is to shrink what rotates: each dot orbits in place with its own transform-origin pinned to the system center, so the bounding box stays inside the circle the dot traces (138px across). Compare item 01, whose ring is already a circle and therefore never grows when it spins. The archive asks for a password when you unpack it; type 3eqz6dw4 and all nine folders open as they are. Item 02 got the same treatment for a different crime — the blur+contrast filter tinted every dot edge red on yellow, and switching to an alpha-only SVG matrix cleaned it up completely.
Accessibility
All nine loaders fall back to animation: none under prefers-reduced-motion: reduce, and each leaves a meaningful static shape rather than vanishing: 03 keeps the blob silhouette, 07 shows a full ring at 100%, 09 stays with curtains open, 04 rests mid-air. Loaders are decoration, so everything except 07 is aria-hidden="true" for assistive tech; 07 instead carries role="status" with a "Loading" label so progress gets announced. Because these loop forever, they meet WCAG 2.2.2 pause, stop, hide only if you remove the loader from the screen when loading actually ends. The media query follows the OS motion setting; MDN documents the details.
FAQ
Can a loading screen be CSS only?
Yes — all nine here ship with zero lines of JavaScript, and even the 07 count-up runs on @property inside plain CSS. The only script you add in production is one line that toggles the .is-loading class when waiting starts and ends. For the classics, see 9 CSS loading spinners and the skeleton screens in skeleton loading UI.
How do I recolor these to match my brand?
Change the single $color variable at the top of each demo's SCSS. Item 01 also blends two helper pastels into its gradient ($subject-lilac, $subject-sky), so adjust those two alongside it. The React port takes the same values as props.
Should I show a percentage when I don't know the real progress?
If you don't know, don't fake it — a counter stuck at 90 makes the wait feel longer, not shorter. Use an open-ended indicator like 01, 02 or 03 when timing is unknown, and reserve 07 for cases with real progress such as uploads. When all you need is the ending moment, 09 is the right tool.