Numeric preloader
A counter runs from 00 to 100 against real asset progress, then the whole screen lifts away to reveal the hero.
Grade: ARuntimes: css · gsapStatus: code-only
Documented, no live demo yet — the code below is complete.
Intent
Buys the few hundred milliseconds the hero needs for its fonts and first image, and converts that wait into the first beat of the page's rhythm rather than a blank screen. The number must track real progress — a fake timed counter is a deliberate delay and users can tell.
Implementation
`Promise.race` against a hard timeout is what keeps a stalled asset from trapping the user behind the overlay.
Dependencies: none
<div class="preloader" id="preloader" aria-hidden="true">
<span class="preloader__count t-display">00</span>
</div>
<style>
.preloader {
position: fixed;
inset: 0;
z-index: 9500;
display: grid;
place-items: center;
background: var(--bg-inverse, #0a0a0a);
color: var(--ink-inverse, #f4f2ee);
transition: transform var(--dur-curtain, 1.1s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1));
}
.preloader.is-out { transform: translateY(-100%); }
.preloader__count { font-variant-numeric: tabular-nums; }
@media (prefers-reduced-motion: reduce) {
.preloader { transition: none; }
}
</style>
<script type="module">
const overlay = document.getElementById("preloader");
if (overlay) {
if (sessionStorage.getItem("seen-preloader")) {
overlay.remove();
} else {
const label = overlay.querySelector(".preloader__count");
const started = performance.now();
const images = [...document.querySelectorAll("img[data-critical]")];
const jobs = [document.fonts.ready, ...images.map((img) => img.decode().catch(() => {}))];
let done = 0;
for (const job of jobs) {
job.then(() => {
done += 1;
label.textContent = String(Math.round((done / jobs.length) * 100)).padStart(2, "0");
});
}
const timeout = new Promise((resolve) => setTimeout(resolve, 2500));
await Promise.race([Promise.allSettled(jobs), timeout]);
// Floor the display time so a warm cache does not produce a flash.
const elapsed = performance.now() - started;
if (elapsed < 400) await new Promise((r) => setTimeout(r, 400 - elapsed));
label.textContent = "100";
sessionStorage.setItem("seen-preloader", "1");
overlay.classList.add("is-out");
overlay.addEventListener("transitionend", () => overlay.remove(), { once: true });
setTimeout(() => overlay.remove(), 1400);
}
}
</script>Use it when
- First visit to a media-heavy landing page.
- When the hero depends on a webfont or a large image that would otherwise pop in.
Avoid it when
- On repeat visits — gate it behind a session flag.
- On any page reachable mid-task, or any page where the content is the point.
- When the page is already fast. A preloader on a 200ms page is pure cost.
Accessibility
- Give the overlay `role="status"` with `aria-live="polite"`, or mark it `aria-hidden` and announce completion once — a counter that announces every integer is unusable.
- A hard timeout is a correctness requirement, not a nicety: if an asset never resolves the overlay must still leave.
- Under reduced motion, remove the overlay without the lift animation.
- The page beneath must be fully functional if the overlay is removed by a script error — never rely on it to gate interactivity.
Performance
- Inline the overlay's markup and critical CSS so it renders on first paint.
- Use `img.decode()` rather than the `load` event to know an image is actually paintable.
- Remove the overlay from the DOM after the exit, not just visually — a fixed full-screen element left behind blocks pointer events.
Knobs
- minimum display
- Floor so a fast load does not flash. Default: 400ms
- maximum display
- Ceiling so a slow asset cannot trap the user. Default: 2500ms
Composition
Conflicts with
None.
Instruction for Claude Code
Add a numeric preloader overlay inlined in the initial HTML: a fixed inset-0 grid-centred panel at z-index 9500 showing a tabular-nums counter. Drive the counter from real progress — document.fonts.ready plus img.decode() for images marked data-critical — not a timer. Race the work against a 2500ms hard timeout, enforce a 400ms minimum display so a warm cache does not flash, set a sessionStorage flag so repeat visits skip it entirely, then translateY(-100%) and remove the element from the DOM on transitionend with a setTimeout backstop. Mark it aria-hidden. Under prefers-reduced-motion, remove it without animating.