Page Loading Animation CSS: 9 Route-Change Demos
Page loading animation css is the feedback layer covering the moment one screen gives way to the next.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Route progress bar that crosses the top of the screen
- 02 Color band that sweeps across and swaps the content behind it
- 03 List where empty outlined slots fill in one card at a time
- 04 Brand mark splash that breathes on the very first entry
- 05 Optimistic move that changes only the title first and lets the body catch up
- 06 Footer loader that fetches the next page at the end of the list
- 07 Panel whose height stretches along as you move between tabs
- 08 Picture that arrives late into a ratio box already holding its place
- 09 Status bar that counts down and reconnects after a dropped link
The nine are ordered by the journey a single navigation takes, not by popularity. First comes the instant right after the click. A thin bar grows across the top (01), a color band sweeps the screen (02), and list slots fill in one by one (03). Next come the first visit and what follows a click. A brand mark breathes on the splash screen (04) and a header swaps its title before the body catches up (05). Then comes the screen you keep reading. The list footer fetches the next page (06), a panel stretches its height as you move between tabs (07), and pictures fade into boxes that already hold their place (08). Last is the moment a move fails. A status bar counts down and reconnects (09). For the spinner side see 9 CSS loading spinners and skeleton UI; for the transition itself see CSS page transitions and loading screen designs.
01Route progress bar that crosses the top of the screen
Click a link and a thin bar at the top grows through five steps, 12 → 38 → 66 → 88 → 100, making the wait for the next screen visible. The fill uses transform: scaleX() so no layout is recomputed, and every step actually updates aria-valuenow. It fits magazine sites where readers hop from list to detail all day, and nineteen of the frames move in the 2-second loop.
var rpSteps = [12, 38, 66, 88, 100];
function rpSet(v) {
rpFill.style.transform = 'scaleX(' + v / 100 + ')';
rpFill.setAttribute('aria-valuenow', String(v));
}
function rpGo(btn) {
rp.classList.remove('is-demo');
rpView.setAttribute('aria-busy', 'true');
rpSet(0);
var k = 0;
rpTimer = setInterval(function () {
rpSet(rpSteps[k]);
k += 1;
if (k < rpSteps.length) return;
clearInterval(rpTimer);
rpView.setAttribute('aria-busy', 'false');
}, 240);
}
02Color band that sweeps across and swaps the content behind it
A single color band slides in from off-screen left, covers the screen completely, then exits right to reveal the new screen behind it. The two pages sit stacked in the same grid cell and swap with a steps(1, end) cut only while the band covers them, so no half-transparent ghost frame ever shows up. It suits portfolio sites where each screen is itself a piece of work.
@keyframes ws-sweep {
0% { transform: translateX(-101%); }
22%, 25% { transform: translateX(0); }
33% { transform: translateX(101%); animation-timing-function: steps(1, end); }
46% { transform: translateX(-101%); }
68%, 71% { transform: translateX(0); }
79%, 100% { transform: translateX(101%); }
}
@keyframes ws-b {
0%, 22% { opacity: 0; }
23%, 68% { opacity: 1; }
69%, 100% { opacity: 0; }
}
wsBand.classList.remove('is-run');
void wsBand.offsetWidth;
wsBand.classList.add('is-run');
setTimeout(function () { wsPages.classList.toggle('is-b'); }, 360);
setTimeout(function () {
wsBand.classList.remove('is-run');
wsPages.setAttribute('aria-busy', 'false');
}, 740);
03List where empty outlined slots fill in one card at a time
Instead of shimmering gray skeletons, dashed outline slots hold the space first, and the cards drop in on a 0, 160, and 320ms animation-delay stagger. Each slot is a display: grid container that stacks the outline and the card in the same cell, so nothing below it shifts, and aria-busy drops to false when the last card lands. It fits search results that refetch the whole list on every filter change.
.of__slot {
display: grid;
min-height: 36px;
}
.of__ghost {
grid-area: 1 / 1;
border: 2px dashed rgba(255, 247, 230, .62);
border-radius: $r-control;
}
.of__card {
grid-area: 1 / 1;
display: flex;
align-items: center;
transform: translateY(0);
transition: transform $duration $easing;
}
.of__list.is-loading .of__card {
transform: translateY(10px);
opacity: 0;
}
.of__list.is-loading .of__slot.is-in .of__card {
transform: translateY(0);
opacity: 1;
}
04Brand mark splash that breathes on the very first entry
A brand mark built from nothing but a circle and a rounded square swells and shrinks with transform: scale, while three dots below bounce in turn. The message sits in an aria-live="polite" region, and the ready state arrives only when the visitor clicks the button. It fits web apps that must prepare a whole first screen like a native app.
spEnter.addEventListener('click', function () {
sp.classList.remove('is-demo');
sp.classList.add('is-ready');
spMsg.textContent = '준비가 끝났어요';
spEnter.disabled = true;
});
.sp__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
opacity: .35;
}
05Optimistic move that changes only the title first and lets the body catch up
The instant a tab is clicked, the big title cuts to the new one and aria-selected moves, but the body hasn't arrived yet: it keeps aria-busy on, holds its place, and rises with translateY 600ms later. The skeleton placeholder turns on at the same moment the swap starts, so no intermediate frame is ever an empty hole. It fits dashboards where people hop between tabs constantly.
function ohGo(i) {
oh.classList.remove('is-demo');
oh.setAttribute('data-tab', String(i));
oh.classList.add('is-loading');
ohBody.setAttribute('aria-busy', 'true');
for (var k = 0; k < ohTabs.length; k++) {
ohTabs[k].setAttribute('aria-selected', k === i - 1 ? 'true' : 'false');
}
clearTimeout(ohTimer);
ohTimer = setTimeout(function () {
oh.classList.remove('is-loading');
ohBody.setAttribute('aria-busy', 'false');
}, 600);
}
06Footer loader that fetches the next page at the end of the list
When the loader card at the bottom of the scroll box becomes 90% visible, an IntersectionObserver appends three real cards to the DOM. The new cards rise on a 90ms stagger, and an aria-live region announces how many arrived. It fits community feeds read as an endless list, and twenty-one of the frames move in the loop.
function infWake() {
if (infIo) return;
inf.classList.remove('is-demo');
infIo = new IntersectionObserver(function (es) {
if (es[0] && es[0].isIntersecting) infAdd();
}, { root: infBox, threshold: 0.9 });
infIo.observe(infLoader);
}
['wheel', 'pointerdown', 'keydown', 'touchstart'].forEach(function (ev) {
infBox.addEventListener(ev, infWake);
});
07Panel whose height stretches along as you move between tabs
The panel interpolates between 0fr and 1fr in grid-template-rows, so it stretches smoothly without ever measuring the content height. The content swap is a cut, the height is eased, and aria-selected marks the current tab. It fits pricing tabs where each description is a different length.
.th__panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.th__panel.is-open { grid-template-rows: 1fr; }
.th__clip { min-height: 0; overflow: hidden; }
.th__body {
padding: $sp-2 0 0;
opacity: 0;
transition: opacity $duration steps(1, end);
}
.th__tab[aria-selected="true"] { background: $subject-ink; color: $color; }
08Picture that arrives late into a ratio box already holding its place
Before the picture arrives, aspect-ratio reserves an empty box of the same size so the text never shifts, and the picture then fades in with opacity alone — no blur. The images really do carry loading="lazy" and decoding="async", and they are SVG data URIs, so there is no external request. It fits photo-heavy articles where the text jumps around as you scroll.
.lz__box {
display: grid;
aspect-ratio: 16 / 9;
border-radius: $r-sm;
overflow: hidden;
}
.lz__ph,
.lz__img {
grid-area: 1 / 1;
width: 100%;
height: 100%;
}
.lz__img {
display: block;
object-fit: cover;
opacity: 1;
transition: opacity $duration $easing;
}
09Status bar that counts down and reconnects after a dropped connection
When the connection drops, the bar turns the error color and a 3 → 2 → 1 second countdown steps down; at zero it flips to reconnected. The real verdict comes from navigator.onLine plus the offline and online events, and a role="status" region announces every wording change. It fits mobile web apps that keep losing the connection on the subway.
function ofDrop() {
ofState('off');
var n = 3;
ofSec(n);
clearInterval(ofTimer);
ofTimer = setInterval(function () {
n -= 1;
if (n > 0) { ofSec(n); return; }
clearInterval(ofTimer);
ofSec(0);
ofState('back');
}, 1000);
}
if (!navigator.onLine) ofDrop();
window.addEventListener('offline', ofDrop);
window.addEventListener('online', ofUp);
Where it breaks — the trap
The first thing to collapse is the poster frame. The renderer picks the frame that differs most from the one before it, and a progress bar completing and vanishing — or a status bar closing — is an exit whose area beats the entrance, so an empty screen ends up as the poster. That is why 01 fills only to 88% in the loop and keeps the completion fade on the real click path, and why 09 never removes the bar: only its wording and color change. Both return within 90% of the 2-second cycle as a single cut frame, because the last of the 24 samples lands at 95.8% and nothing after it is captured. The next thing to collapse is the swap itself. Cross-fading the 02 content swap leaves a translucent ghost in the 4.17% gap between samples, so it cuts instead, and pairing the 03 stagger with the animation: shorthand resets each delay to 0s, so all three slots move at once. Writing out the longhand properties with animation-fill-mode: backwards got those twelve moving frames back. In 07, vertical padding on the collapsing cell survives even at 0fr, a remnant we watched happen before moving the padding to the inner wrapper. The password that unlocks these nine files is wbq4cn6w, and the zip carries the measurements and the React port of every item.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce all nine turn off only the decorative loop and keep the end state. The 04 dots sit at a base opacity of .35 so they stay visible with motion off, and the 03 cards render already filled. The meaning of waiting is carried by ARIA, not by the eye. The progress value in 01 is written as a number in aria-valuenow, the appended count in 06 and the wording changes in 09 are read out by aria-live regions, and 05 and 07 move between tabs with the arrow keys, moving focus with focus({ preventScroll: true }).
| Item | Text / background | Foreground | Background | Ratio |
|---|---|---|---|---|
| 01 body text / panel | cream / ink panel | #fff7e6 |
#2a262a |
13.98:1 |
| 02 heading / card | ink / cream | #17141a |
#fff7e6 |
17.11:1 |
| 03 title / orange stage | ink / orange | #17141a |
#ff4d1f |
5.5:1 |
| 04 message / white stage | ink / white | #17141a |
#ffffff |
18.24:1 |
| 07 selected tab / ink pill | cream / ink | #fff7e6 |
#17141a |
17.11:1 |
| 09 offline wording / error bar | cream / error | #fff7e6 |
#c2361a |
5.13:1 |
| Mouse | Keyboard | Where the state is written | |
|---|---|---|---|
| 01 | click a link | tab focus moves | fill aria-valuenow, body aria-busy |
| 02 | click next screen | button focus, Enter | body aria-busy |
| 03 | click reload | button focus | list aria-busy |
| 04 | click the enter button | button focus | message aria-live |
| 05 | click a tab | arrow keys move | tab aria-selected, body aria-busy |
| 06 | scroll the box | box focus, arrows | aria-live count message |
| 07 | click a tab | arrows plus roving tabIndex | tab aria-selected |
| 08 | click refetch | button focus | image alt, fixed box size |
| 09 | click drop | button focus | bar role="status" |
The verdicts follow the MDN page for aria-busy and the IntersectionObserver docs. For the collapse axis see tab menu transitions and screen transition effects.
FAQ
How long can you trust a fake progress bar?
The fake steps know nothing about the real download, so parking at 88% for a long time becomes a lie. That is why 01 never drives the last step to 100 in the loop and stops at 88%. In production, the honest version measures how much of the response stream has arrived and feeds that into aria-valuenow, and when it cannot, a retry like 09 beats an endless wait.
What separates a skeleton from an outlined placeholder?
A skeleton pre-draws the shape of the content in gray bars; the 03 dashed slot announces only that the space exists. Drawing the shape looks wrong the moment the real content differs, while holding the space cannot mismatch. When even the slot count is unknown, a status message in the 09 style is the right call.
What happens when an optimistic move fails?
It has to be reversible. 05 changes the title first, but if the body never lands within 600ms the aria-busy flag stays on, so the screen never looks falsely complete. In production you layer a way back to the previous screen on top of that. Hiding the failure is not the point; handing the wait back to the person who clicked is.