# GSAP preloader: a loading animation that tracks real progress

> Build a GSAP preloader that tracks real image loading: a counter and progress bar, a curtain exit and a staggered reveal. Complete code included.

Published 2026-09-26 by Jake. Canonical: https://gsapvault.com/blog/gsap-preloader

---
A preloader covers the moment a visitor expects to wait, and it goes wrong in two ways: it runs on a fixed timer and clears before the images have arrived, or it holds a page that finished loading a second ago. Here's a basic version that avoids both. The counter follows the images the browser is actually fetching, the overlay wipes away, and the hero content staggers in behind it.

## What you'll build

A full-screen overlay with a counter from 000 to 100 and a thin progress bar. The number is driven by how many of the page's images have loaded, it holds at 90 until the browser's `load` event fires, and it can never hold the page longer than six seconds. When it completes, the counter lifts away, the overlay wipes upward and anything you mark with `data-reveal` rises into place.

It also fails safe. Without JavaScript, or under `prefers-reduced-motion`, there is no overlay and the page is a plain document. If GSAP fails to load from the CDN, the page is handed back after eight seconds.

[Live interactive demo](https://gsapvault.com/demos/page-preloader/index.html)

## Step 1: Set the loading state in the head

This is the step most preloaders get wrong. If the overlay is switched on by a script at the bottom of the page, the browser has already painted the content once, and the visitor sees a flash of the page before the loader covers it. A few lines in the `<head>` run before the body renders:

```html
<script>
  if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    document.documentElement.classList.add('is-loading');
    // If GSAP never arrives, give the page back anyway
    window.loaderFailsafe = setTimeout(function () {
      document.documentElement.classList.remove('is-loading');
    }, 8000);
  }
</script>
```

Every rule that hides content is keyed on that `is-loading` class, so a visitor without JavaScript never gets it, and neither does anyone who has asked for reduced motion. The failsafe timer covers the remaining case: the class was added but the main script never ran. The main script clears the timer as its first job.

## Step 2: Add the markup

Put the overlay first inside `<body>`, and mark the elements that should arrive after it with `data-reveal`:

```html
<div class="loader">
  <div class="loader-inner">
    <span class="loader-count" aria-hidden="true">000</span>
    <div class="loader-track"><div class="loader-bar"></div></div>
    <span class="visually-hidden" role="status">Loading page</span>
  </div>
</div>

<main>
  <h1 data-reveal>Your headline</h1>
  <p data-reveal>Your standfirst goes here.</p>
  <img data-reveal src="hero.jpg" alt="Describe the image">
</main>
```

The counter is `aria-hidden` because a screen reader announcing "one, four, nine, twenty-three" is noise. The `role="status"` line gives it one plain message instead.

## Step 3: Add the CSS

```css
.loader {
  display: none;
}

/* Lock scrolling while the overlay is up */
.is-loading {
  overflow: hidden;
}

.is-loading .loader {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  background: #111114;
  color: #f4f4f6;
}

.loader-inner {
  width: min(18rem, 70vw);
}

.loader-count {
  display: block;
  font-size: clamp(3rem, 12vw, 6rem);
  font-weight: 600;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.loader-track {
  height: 2px;
  margin-top: 1rem;
  background: rgb(255 255 255 / 0.15);
}

.loader-bar {
  height: 100%;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left center;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Start state for the reveal, only while loading */
.is-loading [data-reveal] {
  opacity: 0;
  transform: translateY(24px);
}
```

The overlay is `display: none` by default and only exists under `is-loading`, which is what makes the no-JavaScript case safe. `font-variant-numeric: tabular-nums` gives every digit the same width, so the counter does not shuffle sideways as "1" turns into "8". The bar grows with `scaleX` rather than `width` so it never triggers layout. And the reveal start state lives in CSS rather than in a `gsap.set()`, because a start state set by JavaScript arrives after the first paint and flickers.

## Step 4: Add the animation

Add GSAP and the script before your closing `</body>` tag:

```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
```

```javascript
(() => {
  const root = document.documentElement;
  if (!root.classList.contains('is-loading')) return;
  clearTimeout(window.loaderFailsafe);

  const loader = document.querySelector('.loader');
  const count = loader.querySelector('.loader-count');
  const bar = loader.querySelector('.loader-bar');

  // Count the images the browser will fetch now. Lazy ones wait for scroll,
  // so waiting on them would hold the loader forever.
  const images = Array.from(document.images).filter((img) => img.loading !== 'lazy');
  let settled = images.filter((img) => img.complete).length;

  const progress = { value: 0 };
  let finished = false;

  function render() {
    count.textContent = String(Math.round(progress.value)).padStart(3, '0');
    bar.style.transform = `scaleX(${progress.value / 100})`;
  }

  // Ease towards the real figure. Stop at 90 until the page has loaded,
  // so the number never claims more than the browser has done.
  function update() {
    const real = images.length ? (settled / images.length) * 100 : 100;
    const target = finished ? 100 : Math.min(real, 90);
    gsap.to(progress, {
      value: target,
      duration: finished ? 0.5 : 0.8,
      ease: 'power2.out',
      overwrite: true,
      onUpdate: render,
      onComplete: finished ? exit : null,
    });
  }

  images.forEach((img) => {
    if (img.complete) return;
    const done = () => { settled++; update(); };
    img.addEventListener('load', done, { once: true });
    img.addEventListener('error', done, { once: true });
  });

  function finish() {
    if (finished) return;
    finished = true;
    update();
  }

  if (document.readyState === 'complete') {
    finish();
  } else {
    window.addEventListener('load', finish, { once: true });
  }
  // Never hold the page longer than this, however slow the connection
  setTimeout(finish, 6000);

  update();

  function exit() {
    gsap.timeline({
      onComplete: () => {
        loader.remove();
        root.classList.remove('is-loading');
      },
    })
      .to('.loader-inner', { opacity: 0, y: -16, duration: 0.35, ease: 'power2.in' })
      .to(loader, { yPercent: -100, duration: 0.9, ease: 'power4.inOut' })
      .to('[data-reveal]', {
        opacity: 1,
        y: 0,
        duration: 0.8,
        ease: 'power3.out',
        stagger: 0.08,
      }, '-=0.45');
  }
})();
```

The counter never jumps straight to the real figure. Each time an image settles, `update()` starts a new tween towards the new target, and `overwrite: true` kills the one in flight, so the number keeps easing smoothly however unevenly the images arrive. Errors count as settled too: one broken image URL should not freeze the loader at 60.

The `load` event covers everything the image count misses (fonts, stylesheets, background images in CSS), which is why the count stops at 90 until it fires. The six-second cap is the promise to visitors on a slow connection: past that point, showing them a half-loaded page beats showing them a counter.

The reveal starts 0.45 seconds before the wipe finishes, so the headline is already rising as the overlay clears. Without that overlap you get two separate animations with a pause between them, and the load reads as slower than it was.

## That's it

Reload the page and the counter climbs as the images arrive, the overlay wipes upward and the hero rises into place behind it. Turn on reduced motion in your OS settings and reload: no overlay, just the page. This basic version handles real progress, a hard time cap, a scroll lock and no-JavaScript fallback.

## Making it yours

- **The exit.** Swap `yPercent: -100` for `opacity: 0` for a quiet fade, or `xPercent: 100` for a sideways wipe. The `power4.inOut` ease is what makes the wipe feel weighted; a linear wipe looks mechanical.
- **The cap.** Six seconds suits an image-heavy portfolio. On a text-led site drop it to three: nobody should wait long for a page that is mostly words.
- **What counts.** If your hero is a video, listen for `canplaythrough` on it in the same way as the images. Count only what is above the fold; anything below it can load while the visitor reads.
- **The reveal.** Keep `stagger` between 0.05 and 0.12. Wider gaps make the last element arrive noticeably late, and on a hero with five or six targets the whole sequence starts to drag.

## A note on performance

A preloader adds no weight to the load itself; GSAP core plus this script is small next to a single hero image. What it can cost is time, by covering a page that is ready. That is the whole reason for tying the counter to real loading and capping it: a loader on a fixed three-second timer adds three seconds to every visit on a fast connection.

Largest Contentful Paint is the metric to watch. While your hero sits under an overlay at `opacity: 0`, the browser may not count it as painted, so a long loader can push LCP back even when the image arrived early. Keep the hold short, and check the page in Lighthouse with the loader on and off to see what it costs you.

---

## Want more control?

This basic version gets you an honest loading sequence with one exit. The [Page Preloader](/effects/page-preloader) is the finished version, and it adds:

- **Four exit styles on one attribute**: a two-tone curtain wipe, split doors, an iris opening from the centre, or a clean fade
- **Once-per-session mode** so returning visitors skip straight to the page
- **A completion event** on `document` for chaining your own hero timeline or analytics off the exit
- **Theatre or wait mode**: a counter on a set duration for a branded moment, or one held until the page has really loaded
- **Data-attribute options** throughout, so the sequence is configured in markup rather than by editing the script

If your site leads with photography, the [Photo Reel Preloader](/effects/photo-reel-preloader) takes a different route: the loader flicks through the page's own photographs as they load, then the last one grows out of its frame to become the full-bleed hero, so the loading screen and the page are one continuous movement.

[View the full effect with all options →](/effects/page-preloader)

*More of this kind in the [GSAP loading animations](/categories/gsap-loading-animations) collection.*