Pixelated Page Transition

A standalone GSAP pixel transition with no Barba.js or Lenis dependency. A CSS grid of square blocks covers the viewport on link click, fading in with a random stagger. Once fully visible, the browser navigates to the next URL. On page load the grid fades back out. Block count adapts to viewport size via adjustGrid(), which reads the column count from CSS.

gsappage transitionpixelvanilla jsno barbaanimation

Setup — External Scripts

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

Code

HTML
html
<div class="transition">
  <div class="transition-block"></div>
</div>
CSS
css
.transition {
  z-index: 100;
  background-color: #ff4c24;
  flex-flow: wrap;
  grid-template-columns: repeat(8, 1fr);
  place-content: center;
  place-items: center;
  width: 100%;
  min-height: 100vh;
  display: none;
  position: fixed;
  top: 0%;
  left: 0%;
  right: 0%;
}

.transition-block {
  aspect-ratio: 1;
  background-color: #ff4c24;
  width: 100%;
}

@media screen and (max-width: 767px) {
  .transition {
    grid-template-columns: repeat(6, 1fr);
  }
}

@media screen and (max-width: 479px) {
  .transition {
    grid-template-columns: repeat(4, 1fr);
  }
}
JavaScript
javascript
function adjustGrid() {
  return new Promise((resolve) => {
    const transition = document.querySelector('.transition');

    // Get computed style of the grid and extract the number of columns
    const computedStyle = window.getComputedStyle(transition);
    const gridTemplateColumns = computedStyle.getPropertyValue('grid-template-columns');
    const columns = gridTemplateColumns.split(' ').length; // Count the number of columns

    const blockSize = window.innerWidth / columns;
    const rowsNeeded = Math.ceil(window.innerHeight / blockSize);

    // Update grid styles
    transition.style.gridTemplateRows = `repeat(${rowsNeeded}, ${blockSize}px)`;

    // Calculate the total number of blocks needed
    const totalBlocks = columns * rowsNeeded;

    // Clear existing blocks
    transition.innerHTML = '';

    // Generate blocks dynamically
    for (let i = 0; i < totalBlocks; i++) {
      const block = document.createElement('div');
      block.classList.add('transition-block');
      transition.appendChild(block);
    }

    // Resolve the Promise after grid creation is complete
    resolve();
  });
}


document.addEventListener("DOMContentLoaded", () => {
  adjustGrid().then(() => {
    let pageLoadTimeline = gsap.timeline({
      onStart: () => {
        gsap.set(".transition", { background: "transparent" });
      },
      onComplete: () => {
        gsap.set(".transition", { display: "none" });
      },
      defaults: {
        ease: "linear"
      }
    });

    // Play the timeline only after the grid is ready
    pageLoadTimeline.to(".transition-block", {
      opacity: 0,
      duration: 0.1,
      stagger: { amount: 0.75, from: "random" },
    }, 0.5);
  });

  // Pre-process all valid links
  const validLinks = Array.from(document.querySelectorAll("a")).filter(link => {
    const href = link.getAttribute("href") || "";
    const hostname = new URL(link.href, window.location.origin).hostname;

    return (
      hostname === window.location.hostname && // Same domain
      !href.startsWith("#") &&                 // Not an anchor link
      link.getAttribute("target") !== "_blank" && // Not opening in a new tab
      !link.hasAttribute("data-transition-prevent") // No 'data-transition-prevent' attribute
    );
  });

  // Add event listeners to pre-processed valid links
  validLinks.forEach(link => {
    link.addEventListener("click", (event) => {
      event.preventDefault();
      const destination = link.href;

      // Show loading grid with animation
      gsap.set(".transition", { display: "grid" });
      gsap.fromTo(
        ".transition-block",
        { autoAlpha: 0 },
        {
          autoAlpha: 1,
          duration: 0.001,
          ease: "linear",
          stagger: { amount: 0.5, from: "random" },
          onComplete: () => {
            window.location.href = destination;
          }
        }
      );
    });
  });

  window.addEventListener("pageshow", (event) => {
    if (event.persisted) {
      window.location.reload();
    }
  });

  window.addEventListener('resize', adjustGrid);
});

Attributes

NameTypeDefaultDescription
data-transition-prevent""Add to any anchor element to exclude it from the transition. Links with this attribute navigate normally without triggering the pixel animation.

Notes

  • Requires only GSAP — no Barba.js, Lenis, or CustomEase needed.
  • The grid regenerates on window resize via a resize listener.
  • The block count and row height are calculated from the live viewport dimensions and the CSS column count — changing the CSS column count is the only configuration needed.
  • On back-forward cache (bfcache) restore the page reloads via the pageshow event to ensure the grid re-runs its exit animation.
  • For performance on mobile, reduce the column count in the CSS media queries.

Guide

Container

Use .transition as the main wrapper that holds all pixel blocks. It should be fixed, cover the entire viewport, and start hidden (display: none).

Grid Generation

The number of columns is detected from your CSS grid-template-columns value on .transition. The script calculates how many rows are required to fill the viewport and populates it with the correct number of .transition-block elements.

Adjusting Columns

To control pixel density, change the column count in CSS:

.transition {
  grid-template-columns: repeat(8, 1fr);
}

Valid Links

The transition only applies to internal links that belong to the same domain, do not open in a new tab, do not start with #, and do not include [data-transition-prevent]. Add [data-transition-prevent] to any link that should skip the animation.