---
name: preloader-animation
description: Use when you want "Branded, polished, expressive" - Shows a branded animation before the page becomes ready.
---

# Preloader Animation

> **Category:** Loading / Transition  -  **Personality:** Branded, polished, expressive
>
> **Best use:** Brand sites, heavy pages
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 3/5

## What it is

A preloader is a branded full-screen overlay that holds the stage while a heavy page boots, then performs a deliberate reveal of the content beneath. It turns unavoidable wait time into a brand moment: a logo lockup, an animated mark, a counting `0 → 100%` readout and a progress hairline, followed by a "shutter" that lifts to uncover the site. Use it on portfolio/agency sites and any page with large hero media, video, fonts or WebGL that would otherwise pop in raggedly. The demo themes it as **Meridian**, a fictional architecture studio: an azure-on-ink loader counts up, then two stacked panels slide away to reveal a daylit concrete hero.

## Dependencies / CDN

**None** - pure CSS + vanilla JS. No libraries.

The demo only loads two Google Fonts (optional - swap for your brand faces):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Playfair+Display:ital,wght@0,500;0,800;1,500&display=swap" rel="stylesheet">
```

## HTML

The overlay is a sibling of the page content, stacked on top of it. (The demo scopes both to a rounded `.stage`; for a real preloader make `.stage` your `<body>` and the loader `position:fixed`.)

```html
<div class="stage" id="stage">

  <!-- the page, revealed underneath -->
  <section class="site">
    <h1>Spaces that hold the <em>light</em>.</h1>
  </section>

  <!-- the preloader overlay (sits ABOVE the page) -->
  <div class="loader" id="loader" role="status" aria-label="Loading">
    <div class="panel panel--accent" aria-hidden="true"></div>
    <div class="panel panel--ink">
      <div class="brand">MERIDIAN</div>
      <div class="count" aria-hidden="true"><span id="num">0</span>%</div>
      <div class="status" id="status">Preparing the space</div>
      <div class="track"><span class="fill" id="fill"></span></div>
    </div>
  </div>

  <button id="replay" type="button">&#8635; Replay</button>
</div>
```

## CSS

```css
/* Clip the overlay to this box. For a real full-page preloader use the
   <body> as the stage and make .loader  position:fixed; inset:0  instead. */
.stage{position:relative; overflow:hidden; border-radius:26px; min-height:640px;
  background:#0e1217; color:#eef1f5; isolation:isolate}

.site{position:absolute; inset:0; z-index:1; display:grid; place-items:center}

/* the overlay sits above the page */
.loader{position:absolute; inset:0; z-index:5}
.panel{position:absolute; inset:0; transition:transform 1s cubic-bezier(.76,0,.24,1)}
.panel--accent{z-index:1; background:#79acdf}                 /* behind */
.panel--ink   {z-index:2; background:#141b23; padding:36px;   /* front  */
  display:flex; flex-direction:column; justify-content:space-between}

/* THE REVEAL: shutter both panels up; the accent lags a beat for depth */
.stage.is-revealed .panel--ink   {transform:translateY(-101%)}
.stage.is-revealed .panel--accent{transform:translateY(-101%); transition-delay:.12s}
.stage.is-revealed .loader{pointer-events:none}

/* counter - monospace + tabular-nums so digits don't jitter while counting */
.count{align-self:center; margin:auto 0; font-variant-numeric:tabular-nums;
  font:500 clamp(72px,17vw,170px)/1 'IBM Plex Mono',monospace}

/* progress hairline */
.track{height:2px; background:rgba(238,241,245,.14); border-radius:2px; overflow:hidden}
.fill {display:block; height:100%; width:0; background:#79acdf}

@media (prefers-reduced-motion:reduce){ .panel{transition:none} }
```

## JavaScript

```js
var stage  = document.getElementById('stage'),
    loader = document.getElementById('loader'),
    num    = document.getElementById('num'),
    fill   = document.getElementById('fill'),
    status = document.getElementById('status'),
    replay = document.getElementById('replay'),
    reduce = matchMedia('(prefers-reduced-motion: reduce)').matches,
    PHASES = ['Preparing the space','Composing the light','Casting the concrete','Almost ready'],
    DURATION = 2600, rafId, timer, t0;

function easeOut(t){ return 1 - Math.pow(1 - t, 3); }

function paint(p){                              // p: 0..100
  num.textContent  = Math.round(p);
  fill.style.width = p + '%';
  status.textContent = PHASES[Math.min(PHASES.length - 1, Math.floor(p/100*PHASES.length))];
}
function reveal(){ stage.classList.add('is-revealed'); loader.setAttribute('aria-hidden','true'); }

function run(){
  cancelAnimationFrame(rafId); clearTimeout(timer);
  stage.classList.remove('is-revealed'); loader.removeAttribute('aria-hidden');
  paint(0);

  if(reduce){ paint(100); reveal(); return; }   // reduced motion: skip the count, reveal at once

  t0 = 0;
  function step(ts){
    if(!t0) t0 = ts;
    var t = Math.min(1, (ts - t0) / DURATION);
    paint(easeOut(t) * 100);
    if(t < 1) rafId = requestAnimationFrame(step);
    else timer = setTimeout(reveal, 420);       // brief hold at 100%, then reveal
  }
  rafId = requestAnimationFrame(step);
}

replay.addEventListener('click', run);
run();                                          // auto-play on load
```

> Replay tip (used in the live demo): before re-running, snap the overlay back over the page **without** a transition - add a `.no-anim{transition:none!important;animation:none!important}` class, force a reflow with `void stage.offsetWidth`, then remove it - otherwise the panels visibly slide back *down* to re-cover the page.

## How it works

- **Stacking.** The page (`.site`) and the overlay (`.loader`) are siblings inside a positioned, `overflow:hidden` stage. The loader's higher `z-index` parks it on top until the reveal.
- **The counter** is driven by `requestAnimationFrame`: each frame computes elapsed/`DURATION`, runs it through an `easeOut` curve, and writes the rounded percent + bar width. rAF (not `setInterval`) keeps it synced to the display refresh and pauses in background tabs.
- **The reveal** is a single class toggle. `.is-revealed` translates both panels up by `-101%`. The front **ink** panel leaves first; the **accent** panel behind it is delayed `.12s`, so a sliver of brand colour flashes through - that lag is what makes it feel layered and intentional rather than a flat fade.
- **Phases** swap a short status line ("Preparing the space" -> "Almost ready") at quartile thresholds so the wait reads as progress, not a spinner.
- **Only `transform` and `opacity`** animate, so the whole sequence stays on the compositor with no layout reflow.

## Customization

- **Pace & feel:** change `DURATION` and the easing (`easeOut` -> `easeInOutCubic` for a slower settle). Adjust the panel `cubic-bezier` for the shutter's snap.
- **Reveal direction:** translate `X` instead of `Y` for a side wipe, or use `clip-path: inset()` for an iris/blind. Add a third panel for a richer multi-stage curtain.
- **Brand mark:** drop in an SVG logo and animate it - `stroke-dashoffset` to "draw" it, or a slow `rotate` on one element (the demo spins the meridian ellipse in its monogram).
- **Palette:** the demo is one ink + one accent var; re-skin by changing `.panel--ink` / `.panel--accent` backgrounds and the accent colour.
- **Real progress (recommended):** replace the timed counter by gating on actual readiness - preload key images and resolve a `Promise.all` of their `onload`, or listen for `window.addEventListener('load', ...)`, and map real bytes/asset counts onto `paint()`.

## Accessibility & performance

- **Reduced motion:** the script checks `matchMedia('(prefers-reduced-motion: reduce)')` first and, when set, skips the count loop and reveals immediately; the CSS also drops the panel transition. Never trap a reduced-motion user behind animation.
- **Screen readers:** the fast-ticking number carries `aria-hidden="true"` so it isn't announced 100 times; the loader is a `role="status"` region so the few human-readable phase changes are announced politely. Set `aria-hidden="true"` on the loader once revealed.
- **Don't steal focus / scroll** longer than needed; if you lock `body{overflow:hidden}` during loading, restore it in `reveal()`.
- **Cheap to run:** animate only `transform`/`opacity`, keep the DOM tiny, and avoid animating `filter`/`box-shadow`/`width` on the panels.

## Gotchas

- **It must always finish.** A timed counter is *fake* progress; if you gate on real assets, add a max-timeout fallback (e.g. reveal after 6 s no matter what) or a failed image will trap the page behind the loader forever.
- **`-101%`, not `-100%`.** A sub-pixel rounding seam can leave a 1px line of the panel at the top edge; over-translating hides it.
- **Restarting a CSS animation needs a reflow.** To replay (or re-run the entrance), remove the class, read `void el.offsetWidth`, then re-add it - browsers coalesce add/remove in one frame otherwise and nothing happens.
- **Tabular numerals matter.** A proportional font makes the counter's width jump as it goes `9 -> 10 -> 100`; use a monospace face or `font-variant-numeric: tabular-nums`.
- **Scope vs. real use.** This demo uses `position:absolute` to stay inside the catalog stage. A production preloader covers the viewport with `position:fixed; inset:0` - and remember a `fixed` element is relative to the viewport, not a transformed ancestor.
- **Fake progress that outlasts readiness** feels broken - never let the bar sit at 99% waiting; ease it to 100 only once you're actually ready to reveal.
