GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

The nine are ordered by the journey a single loading page transition 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

What this page loader animation css shows

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.

Route progress bar that crosses the top of the screen
Open in a new tab

01 · Route progress bar that crosses the top of the screen — Measured area 5.168% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="progressbar"aria-valuenowscaleX

Where to look in the code

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

What happens on screen

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.

Color band that sweeps across and swaps the content behind it
Open in a new tab

02 · Color band that sweeps across and swaps the content behind it — Measured area 25.842% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

translateXsteps(1, end)aria-busy

How to read the code

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

What moves

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.

List where empty outlined slots fill in one card at a time
Open in a new tab

03 · List where empty outlined slots fill in one card at a time — Measured area 29.383% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

aria-busyanimation-delayanimation-fill-mode: backwards

What to change

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

What you see

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.

Brand mark splash that breathes on the very first entry
Open in a new tab

04 · Brand mark splash that breathes on the very first entry — Measured area 0.917% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password

transform: scaleanimation-delayaria-live

Where to look in the code

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 = 'All set';
  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

What happens on screen

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.

Optimistic move that changes only the title first and lets the body catch up
Open in a new tab

05 · Optimistic move that changes only the title first and lets the body catch up — Measured area 9.949% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

steps(1, end)aria-busytranslateY

How to read the code

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

What moves

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.

Footer loader that fetches the next page at the end of the list
Open in a new tab

06 · Footer loader that fetches the next page at the end of the list — Measured area 9.483% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

IntersectionObserveraria-livetranslateY

What to change

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

What you see

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.

Panel whose height stretches along as you move between tabs
Open in a new tab

07 · Panel whose height stretches along as you move between tabs — Measured area 36.456% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password

grid-template-rows0fraria-selected

Where to look in the code

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

What happens on screen

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.

Picture that arrives late into a ratio box already holding its place
Open in a new tab

08 · Picture that arrives late into a ratio box already holding its place — Measured area 10.714% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

aspect-ratiodecoding="async"opacity

How to read the code

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

What moves

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.

Status bar that counts down and reconnects after a dropped link
Open in a new tab

09 · Status bar that counts down and reconnects after a dropped link — Measured area 9.118% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

role="status"steps(1, end)navigator.onLine

What to 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 in a website loading animation css?

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. One more way to soften the wait itself is to let a character breathe through it, which is what the mascot animations are for.

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.

Enter the archive password

The password is inside this article. You will find it as you read.