Willem Loading Animation

GSAP-powered page loader featuring an expanding image box that grows to fill the viewport, revealing the header content beneath. Uses letter-by-letter stagger animations and layered cover images for a cinematic entrance.

gsaploaderanimationheroimagetimeline

Setup — External Scripts

CDN — GSAP (add before </body>)
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>

Code

HTML
html
<!-- .is--loading disables scroll; .is--hidden keeps it invisible until JS runs -->
<section class="willem-header is--loading is--hidden">

  <!-- Loader overlay (centered word + expanding image box) -->
  <div class="willem-loader">
    <div class="willem__h1">
      <div class="willem__h1-start">
        <span class="willem__letter">W</span>
        <span class="willem__letter">i</span>
        <span class="willem__letter">l</span>
      </div>

      <!-- Expanding image box -->
      <div class="willem-loader__box">
        <div class="willem-loader__box-inner">
          <div class="willem__growing-image">
            <div class="willem__growing-image-wrap">
              <!-- Extra images fade out in sequence (z-index 3 → 2 → 1) -->
              <img class="willem__cover-image-extra is--1" src="https://cdn.prod.website-files.com/6915bbf51d482439010ee790/6915bc3ac9fe346a924724bc_minimalist-architecture-2.avif" loading="lazy" alt="">
              <img class="willem__cover-image-extra is--2" src="https://cdn.prod.website-files.com/6915bbf51d482439010ee790/6915bc3ac9fe346a924724cf_minimalist-architecture-4.avif" loading="lazy" alt="">
              <img class="willem__cover-image-extra is--3" src="https://cdn.prod.website-files.com/6915bbf51d482439010ee790/6915bc3ac9fe346a924724c5_minimalist-architecture-3.avif" loading="lazy" alt="">
              <!-- Final image remains visible during expansion -->
              <img class="willem__cover-image" src="https://cdn.prod.website-files.com/6915bbf51d482439010ee790/6915bc3ac9fe346a924724b0_minimalist-architecture-1.avif" loading="lazy" alt="">
            </div>
          </div>
        </div>
      </div>

      <div class="willem__h1-end">
        <span class="willem__letter">l</span>
        <span class="willem__letter">e</span>
        <span class="willem__letter">m</span>
      </div>
    </div>
  </div>

  <!-- Actual header content (revealed after loader) -->
  <div class="willem-header__content">
    <div class="willem-header__top">
      <nav class="willen-nav">
        <div class="willem-nav__start">
          <a href="#" class="willem-nav__link">Osmo ©</a>
        </div>
        <div class="willem-nav__end">
          <div class="willem-nav__links">
            <a href="#" class="willem-nav__link">Projects,</a>
            <a href="#" class="willem-nav__link">Services,</a>
            <a href="#" class="willem-nav__link">Blog (13)</a>
          </div>
          <div class="willem-nav__cta">
            <a href="#" class="willem-nav__link">Get in touch</a>
          </div>
        </div>
      </nav>
    </div>
    <div class="willem-header__bottom">
      <div class="willem__h1">
        <span class="willem__letter-white">W</span>
        <span class="willem__letter-white">i</span>
        <span class="willem__letter-white">l</span>
        <span class="willem__letter-white">l</span>
        <span class="willem__letter-white">e</span>
        <span class="willem__letter-white">m </span>
        <span class="willem__letter-white is--space">©</span>
      </div>
    </div>
  </div>

</section>
CSS
css
/* Prevent scroll while loading */
main:has(.willem-header.is--loading) {
  height: 100dvh;
  overflow: hidden;
}

.willem-header {
  color: #f4f4f4;
  position: relative;
  overflow: hidden;
}

/* Hidden until JS removes the class */
.willem-header.is--loading.is--hidden {
  display: none;
}

/* Loader overlay */
.willem-loader {
  color: #201d1d;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: absolute;
  top: 0;
  left: 0;
  overflow: hidden;
}

.willem__h1 {
  white-space: nowrap;
  justify-content: center;
  font-size: 12.5em;
  font-weight: 500;
  line-height: .75;
  display: flex;
  position: relative;
}

.willem__h1-start {
  justify-content: flex-end;
  width: 1.5256em;
  display: flex;
  overflow: hidden;
}

.willem__h1-end {
  justify-content: flex-start;
  width: 1.525em;
  display: flex;
  overflow: hidden;
}

.willem__letter {
  display: block;
  position: relative;
}

.willem__letter-white.is--space {
  margin-left: .25em;
}

/* Expanding box */
.willem-loader__box {
  flex-flow: column;
  justify-content: center;
  align-items: center;
  width: 0;
  display: flex;
  position: relative;
}

.willem-loader__box-inner {
  justify-content: center;
  align-items: center;
  min-width: 1em;
  height: 95%;
  display: flex;
  position: relative;
}

.willem__growing-image {
  justify-content: center;
  align-items: center;
  width: 0%;
  height: 100%;
  display: flex;
  position: absolute;
  overflow: hidden;
}

.willem__growing-image-wrap {
  width: 100%;
  min-width: 1em;
  height: 100%;
  position: absolute;
}

.willem__cover-image,
.willem__cover-image-extra {
  pointer-events: none;
  object-fit: cover;
  -webkit-user-select: none;
  user-select: none;
  width: 100%;
  height: 100%;
  position: absolute;
  top: 0;
  left: 0;
}

.willem__cover-image-extra.is--1 { z-index: 3; }
.willem__cover-image-extra.is--2 { z-index: 2; }
.willem__cover-image-extra.is--3 { z-index: 1; }

/* Header content */
.willem-header__content {
  flex-flow: column;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  min-height: 100dvh;
  padding: 3em;
  display: flex;
  position: relative;
}

.willem-header__top {
  width: 100%;
  position: relative;
}

.willem-header__bottom {
  justify-content: flex-start;
  align-items: flex-end;
  width: 100%;
  display: flex;
  position: relative;
  overflow: hidden;
}

/* Nav */
.willen-nav {
  display: flex;
  position: relative;
  overflow: hidden;
}

.willem-nav__start {
  justify-content: flex-start;
  align-items: flex-start;
  width: 50%;
  display: flex;
}

.willem-nav__end {
  justify-content: space-between;
  align-items: flex-start;
  width: 50%;
  display: flex;
}

.willem-nav__links {
  grid-column-gap: .5em;
  grid-row-gap: .5em;
  display: flex;
}

.willem-nav__link {
  color: inherit;
  font-size: 1.3125em;
  line-height: 1.3;
  text-decoration: none;
  position: relative;
}

.willem__letter-white {
  display: block;
  position: relative;
}

@media screen and (max-width: 991px) {
  .willem__h1 { font-size: 9em; }
  .willem-nav__links {
    grid-column-gap: 0;
    grid-row-gap: 0;
    flex-flow: column;
  }
}

@media screen and (max-width: 767px) {
  .willem__h1 { font-size: 5.5em; }
  .willem-nav__start { width: 65%; }
  .willem-nav__end {
    grid-column-gap: 1.5em;
    grid-row-gap: 1.5em;
    flex-flow: column;
    width: 45%;
  }
}
JavaScript
javascript
function initWillemLoadingAnimation() {
  const container = document.querySelector('.willem-header');
  const loadingLetter  = container.querySelectorAll('.willem__letter');
  const box            = container.querySelectorAll('.willem-loader__box');
  const growingImage   = container.querySelectorAll('.willem__growing-image');
  const headingStart   = container.querySelectorAll('.willem__h1-start');
  const headingEnd     = container.querySelectorAll('.willem__h1-end');
  const coverImageExtra = container.querySelectorAll('.willem__cover-image-extra');
  const headerLetter   = container.querySelectorAll('.willem__letter-white');
  const navLinks       = container.querySelectorAll('.willen-nav a');

  const tl = gsap.timeline({
    defaults: { ease: 'expo.inOut' },
    onStart: () => container.classList.remove('is--hidden'),
  });

  // 1. Letters animate up into view
  if (loadingLetter.length) {
    tl.from(loadingLetter, { yPercent: 100, stagger: 0.025, duration: 1.25 });
  }

  // 2. Box expands from 0 to 1em wide
  if (box.length) {
    tl.fromTo(box, { width: '0em' }, { width: '1em', duration: 1.25 }, '< 1.25');
  }

  // 3. Image grows to fill the box
  if (growingImage.length) {
    tl.fromTo(growingImage, { width: '0%' }, { width: '100%', duration: 1.25 }, '<');
  }

  // 4. Word halves spread apart slightly
  if (headingStart.length) {
    tl.fromTo(headingStart, { x: '0em' }, { x: '-0.05em', duration: 1.25 }, '<');
  }
  if (headingEnd.length) {
    tl.fromTo(headingEnd, { x: '0em' }, { x: '0.05em', duration: 1.25 }, '<');
  }

  // 5. Extra cover images fade out in sequence (reveals final image)
  if (coverImageExtra.length) {
    tl.fromTo(coverImageExtra,
      { opacity: 1 },
      { opacity: 0, duration: 0.05, ease: 'none', stagger: 0.5 },
      '-=0.05'
    );
  }

  // 6. Image + box expand to fill entire viewport
  if (growingImage.length) {
    tl.to(growingImage, { width: '100vw', height: '100dvh', duration: 2 }, '< 1.25');
  }
  if (box.length) {
    tl.to(box, { width: '110vw', duration: 2 }, '<');
  }

  // 7. Header title letters animate in
  if (headerLetter.length) {
    tl.from(headerLetter, { yPercent: 100, duration: 1.25, ease: 'expo.out', stagger: 0.025 }, '< 1.2');
  }

  // 8. Nav links animate in
  if (navLinks.length) {
    tl.from(navLinks, { yPercent: 100, duration: 1.25, ease: 'expo.out', stagger: 0.1 }, '<');
  }
}

// Initialize Willem Loading Animation
document.addEventListener('DOMContentLoaded', () => {
  initWillemLoadingAnimation();
});

Attributes

NameTypeDefaultDescription
.is--loadingclassAdd to .willem-header. Locks page scroll via the CSS :has() rule while the loader is active.
.is--hiddenclassAdd to .willem-header alongside .is--loading. Keeps the section display:none until the GSAP timeline starts (onStart removes it).
.willem__cover-image-extra.is--1/2/3classLayered images (z-index 3→1) that fade out sequentially during the animation, creating a flicker effect before the final image is revealed.
.willem__cover-imageclassThe final/main image that remains visible as the box expands to fill the viewport.

Notes

  • Requires GSAP loaded via CDN before the script runs.
  • The .is--hidden class is removed by the GSAP onStart callback — the section stays invisible until the timeline begins.
  • The CSS :has() rule locks scroll on the parent <main> while .is--loading is present. Remove .is--loading from the element once your page is fully loaded if you want to unlock scroll programmatically.
  • The 4 images (3 extras + 1 main) should ideally be the same subject/crop — the extras create a brief multi-image flicker effect before settling on the final image.
  • Font size uses em units throughout — pair with the Osmo Scaling System for fluid responsive sizing.
  • The GSAP timeline uses position syntax ("<", "< 1.25") to overlap steps — adjust duration/offset values to tune the pacing.