Full-Screen Percentage Counter Loader — Eased 0-100% Takeover
Full-Screen Percentage Counter Loader · Loaders · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="pm-overlay" id="pmOverlay">
<div class="pm-num" id="pmNum">0<span class="pm-sign">%</span></div>
<div class="pm-label" id="pmLabel">Loading experience…</div>
</div>
<div class="pm-page">
<h1>Welcome back</h1>
<p>The real page content sits underneath the takeover.</p>
<button type="button" class="pm-replay" id="pmReplay">↻ Replay loader</button>
</div>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#0b0f1a;color:#fff;min-height:100vh;display:flex;align-items:center;justify-content:center}
.pm-page{text-align:center;padding:24px}
.pm-page h1{font-size:28px;letter-spacing:-.02em;margin-bottom:8px}
.pm-page p{color:#8a93ad;font-size:14px;margin-bottom:18px}
.pm-replay{padding:10px 18px;background:#1c2338;border:1px solid #2b3350;color:#c3cadf;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;font-family:inherit}
.pm-replay:hover{background:#242c47}
.pm-overlay{position:fixed;inset:0;z-index:50;background:#05070d;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;transition:opacity .6s ease;opacity:1}
.pm-overlay.pm-hide{opacity:0;pointer-events:none}
.pm-num{font-size:clamp(64px,18vw,180px);font-weight:800;letter-spacing:-.04em;font-variant-numeric:tabular-nums;line-height:1;color:#fff;transition:transform .12s ease}
.pm-num.pm-pulse{transform:scale(1.06)}
.pm-sign{font-size:.4em;font-weight:700;color:#6b7bff;margin-left:4px}
.pm-label{font-size:13px;letter-spacing:.06em;text-transform:uppercase;color:#6b7591;transition:opacity .25s}var overlay = document.getElementById('pmOverlay');
var numEl = document.getElementById('pmNum');
var labelEl = document.getElementById('pmLabel');
var replayBtn = document.getElementById('pmReplay');
var rafId = null;
// Cubic ease-out: fast start, gentle settle — never a linear tick-tick-tick count.
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
var LABELS = [
[0, 'Loading experience…'],
[35, 'Fetching assets…'],
[70, 'Almost ready…'],
[100, 'Ready'],
];
function labelFor(pct) {
var current = LABELS[0][1];
for (var i = 0; i < LABELS.length; i++) {
if (pct >= LABELS[i][0]) current = LABELS[i][1];
}
return current;
}
function runCounter() {
overlay.classList.remove('pm-hide');
var duration = 2600;
var start = null;
var lastShown = -1;
function frame(ts) {
if (start === null) start = ts;
var t = Math.min(1, (ts - start) / duration);
var eased = easeOutCubic(t);
var pct = Math.round(eased * 100);
if (pct !== lastShown) {
lastShown = pct;
numEl.firstChild.textContent = pct;
// A quick scale pulse on every digit change makes the count feel alive
// rather than a flat text update.
numEl.classList.remove('pm-pulse');
void numEl.offsetWidth;
numEl.classList.add('pm-pulse');
var newLabel = labelFor(pct);
if (newLabel !== labelEl.textContent) labelEl.textContent = newLabel;
}
if (t < 1) {
rafId = requestAnimationFrame(frame);
} else {
setTimeout(function () {
overlay.classList.add('pm-hide');
}, 500);
}
}
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(frame);
}
replayBtn.addEventListener('click', runCounter);
runCounter();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Full-Screen Percentage Counter Loader — Eased Digits, Pulse-on-Update, Fade-to-Reveal

A handful of premium sites — portfolios, product launches, agency homepages — greet visitors with a full-screen percentage counting up before the real page fades in. Done badly it's a linear digit-flip that feels mechanical; done well the count decelerates like it's genuinely arriving somewhere, and each digit change has a tiny physical pulse. This snippet builds the effect properly: a requestAnimationFrame loop driven by a real cubic ease-out, a scale pulse on every number change, staged status text, and a fade-out that reveals the underlying page — all in plain HTML, CSS, and vanilla JavaScript.
Why requestAnimationFrame with real easing, not setInterval
The counter is driven entirely by runCounter()'s frame() function inside a requestAnimationFrame loop, not a setInterval incrementing by one. Each frame computes elapsed time as a fraction of a fixed 2.6s duration, runs it through easeOutCubic(t) = 1 - (1 - t)³, and multiplies by 100 to get the displayed percentage. Because the easing curve's derivative is steep near t = 0 and flattens toward t = 1, the count visibly races through the early numbers and decelerates into a gentle settle at 100 — the same "arriving, not ticking" feel a countdown clock lacks. A linear count (no easing function at all) would tick at a constant rate and read as mechanical rather than deliberate.
A pulse tied to actual digit changes, not a fixed timer
Every time the displayed integer percentage changes — checked with pct !== lastShown so a frame that rounds to the same number never re-triggers anything — the number element's pm-pulse class is removed, the layout is force-reflowed with void numEl.offsetWidth, and the class is re-added, restarting a quick scale(1.06) CSS transition. This ties the visual "beat" of the animation to genuine data changes rather than a separate decorative timer running alongside the count, so the pulse and the number are always perfectly synchronized, even though the frame rate driving them isn't fixed.
Staged status text underneath the number
A small LABELS lookup swaps a status line ("Loading experience…", "Fetching assets…", "Almost ready…", "Ready") at percentage thresholds, computed by labelFor() from the same eased percentage driving the digits — so the label text and the number are always in lockstep, derived from one shared value rather than a second independent timer that could drift out of sync.
Full-viewport takeover and reveal
The counter sits in a position: fixed; inset: 0 overlay above the real page content, which renders normally underneath. Once the animation reaches t = 1, a short pause holds the finished "100% / Ready" state, then pm-hide fades the whole overlay's opacity to 0 with pointer-events: none, revealing the page beneath — the counter itself never has to know what page it's covering.
Tuning and reuse
Change duration for a snappier or slower count, swap easeOutCubic for a different easing function (a bounce or an elastic ease reads very differently), or drive the percentage from real asset-loading progress (a window.onload, a resource count, or a preloader library's callback) instead of a fixed-duration animation — the eased-display and pulse logic works identically either way. Pair it with a loading overlay for shorter in-page waits or a top loading bar for route transitions.
Step by step
How to Use
- 1Paste HTML, CSS, and JSA full-screen dark overlay appears with a huge "0%" and begins counting immediately.
- 2Watch it accelerate then settleThe count races through the early numbers and eases into 100% rather than ticking at a constant rate.
- 3Notice the pulseEvery digit change gives the number a quick scale pulse, tied to the actual value changing.
- 4Watch the status textThe label beneath the number advances through stages as the percentage crosses thresholds.
- 5See it fade and revealAt 100%, it holds briefly on "Ready" then fades out to reveal the real page.
- 6Replay itClick "Replay loader" to re-run the entire counting sequence from zero.
Real-world uses
Common Use Cases
easeOutCubic racing early and decelerating into 100 so the count feels earned, not mechanical.void offsetWidth forcing a reflow so every pulse replays.Got questions?
Frequently Asked Questions
requestAnimationFrame runs in sync with the browser's paint cycle, giving smooth, frame-accurate timing, and it naturally supports computing progress as elapsed-time-over-duration each frame — which is what makes real easing possible. A setInterval incrementing by a fixed step every tick can only count linearly and drifts from real elapsed time if the tab is throttled.
easeOutCubic(t) = 1 - (1 - t)³ takes a linear progress fraction from 0 to 1 and reshapes it so the output changes quickly near t = 0 and slows sharply near t = 1. Multiplying that eased value by 100 produces a percentage that visibly races through the early numbers and settles gently into 100 — a real mathematical curve, not a scripted slowdown.
The pulse is gated on pct !== lastShown, so it only fires when the displayed integer percentage genuinely changes — since requestAnimationFrame can run many times between one integer and the next, most frames update nothing visually. This keeps the pulse tied to real data changes rather than firing dozens of times a second regardless of whether the number moved.
Replace the elapsed-time calculation in frame() with your actual progress source — for example a running count of resources loaded divided by total resources, or values from a preloading library's progress callback — and skip the eased time-based ramp entirely, applying easeOutCubic (or no easing) directly to that real fraction instead. The pulse, label, and fade-out logic all stay identical.
Hold the displayed percentage in state and run the same requestAnimationFrame loop inside a mount effect, updating state only when the rounded integer changes so re-renders match the pulse-trigger condition exactly. Clean up by cancelling the animation frame on unmount. The CSS transitions and full-viewport overlay markup port unchanged.