AnimationsAdvancedApril 8, 2026

Pixel Grid Transition

Full-viewport grid of colored blocks that fades in randomly on navigation and out on page load, creating a pixelated wipe effect. Grid density adapts to the container size.

View Full Demo →

Home

index.jsx
"use client";
import { useEffect, useRef, useState } from "react";
import gsap from "gsap";
import styles from "./styles.module.css";

const PAGES = [
  { key: "home",  label: "Home",  bg: "#f5f5f5", color: "#111111" },
  { key: "about", label: "About", bg: "#111111", color: "#f5f5f5" },
  { key: "work",  label: "Work",  bg: "#1a1f2e", color: "#e0e0e0" },
];

function calcGrid(el) {
  const w = el.clientWidth;
  const h = el.clientHeight;
  const cols = w <= 479 ? 4 : w <= 767 ? 6 : 8;
  const blockSize = w / cols;
  const rows = Math.ceil(h / blockSize);
  return { cols, rows, blockSize, total: cols * rows };
}

export default function PixelGridTransition({ color = "#ff4c24" }) {
  const demoRef  = useRef(null);
  const overlayRef = useRef(null);
  const isAnimating = useRef(false);
  const entranceDone = useRef(false);

  const [pageIndex, setPageIndex] = useState(0);
  const [grid, setGrid] = useState({ cols: 8, rows: 0, blockSize: 0, total: 0 });

  // Recalculate grid whenever the container resizes
  useEffect(() => {
    const el = demoRef.current;
    if (!el) return;
    const ro = new ResizeObserver(() => setGrid(calcGrid(el)));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Entrance: play once after the first grid calculation
  useEffect(() => {
    if (entranceDone.current || grid.total === 0) return;
    if (!overlayRef.current) return;
    entranceDone.current = true;

    const overlay = overlayRef.current;
    const blocks  = overlay.querySelectorAll("[data-block]");

    overlay.style.display = "grid";
    gsap.set(blocks, { autoAlpha: 1 });
    gsap.to(blocks, {
      autoAlpha: 0,
      duration: 0.1,
      delay: 0.3,
      ease: "linear",
      stagger: { amount: 0.75, from: "random" },
      onComplete: () => { overlay.style.display = "none"; },
    });
  }, [grid.total]);

  function navigate(nextIndex) {
    if (isAnimating.current || nextIndex === pageIndex) return;
    if (!overlayRef.current) return;

    isAnimating.current = true;
    const overlay = overlayRef.current;
    const blocks  = overlay.querySelectorAll("[data-block]");

    overlay.style.display = "grid";

    // Fade blocks IN → swap page → fade blocks OUT
    gsap.fromTo(
      blocks,
      { autoAlpha: 0 },
      {
        autoAlpha: 1,
        duration: 0.001,
        ease: "linear",
        stagger: { amount: 0.5, from: "random" },
        onComplete: () => {
          setPageIndex(nextIndex);
          gsap.to(blocks, {
            autoAlpha: 0,
            duration: 0.1,
            delay: 0.15,
            ease: "linear",
            stagger: { amount: 0.75, from: "random" },
            onComplete: () => {
              overlay.style.display = "none";
              isAnimating.current = false;
            },
          });
        },
      }
    );
  }

  const page = PAGES[pageIndex];

  return (
    <div ref={demoRef} className={styles.demo}>
      {/* Pixel overlay — position: absolute so it stays in the demo container */}
      <div
        ref={overlayRef}
        className={styles.overlay}
        style={{
          gridTemplateColumns: `repeat(${grid.cols}, 1fr)`,
          gridTemplateRows: `repeat(${grid.rows}, ${grid.blockSize}px)`,
        }}
      >
        {Array.from({ length: grid.total }).map((_, i) => (
          <div key={i} data-block="" className={styles.block} style={{ backgroundColor: color }} />
        ))}
      </div>

      {/* Page content */}
      <div className={styles.page} style={{ background: page.bg, color: page.color }}>
        <h2 className={styles.pageTitle}>{page.label}</h2>
      </div>

      {/* Nav */}
      <nav className={styles.nav}>
        {PAGES.map((p, i) => (
          <button
            key={p.key}
            className={`${styles.navBtn} ${i === pageIndex ? styles.navBtnActive : ""}`}
            onClick={() => navigate(i)}
          >
            {p.label}
          </button>
        ))}
      </nav>
    </div>
  );
}
styles.module.css
.demo {
  position: relative;
  width: 100%;
  height: 100dvh;
  overflow: hidden;
}

/* ── Pixel overlay ── */

.overlay {
  position: absolute;
  inset: 0;
  display: none; /* shown/hidden via JS */
  z-index: 100;
}

.block {
  width: 100%;
  height: 100%;
}

/* ── Page content ── */

.page {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

.pageTitle {
  font-size: clamp(2.5rem, 10vw, 7rem);
  font-weight: 700;
  letter-spacing: -0.04em;
  margin: 0;
}

/* ── Nav ── */

.nav {
  position: absolute;
  bottom: 2rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  gap: 0.5rem;
  z-index: 10;
}

.navBtn {
  padding: 0.45rem 1.1rem;
  border: 1px solid rgba(128, 128, 128, 0.4);
  background: rgba(128, 128, 128, 0.1);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: inherit;
  cursor: pointer;
  font-size: 0.8rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border-radius: 2rem;
  transition: background 0.2s, border-color 0.2s;
}

.navBtn:hover {
  background: rgba(128, 128, 128, 0.2);
}

.navBtnActive {
  background: rgba(128, 128, 128, 0.3);
  border-color: rgba(128, 128, 128, 0.7);
}
  • gsap

Apr 27, 2026

ANIMATIONS

Cursor Hover Label

A custom cursor label that follows the mouse and fades in when hovering trigger elements. Uses GSAP quickTo for smooth tracking. Trigger elements use data-cursor-label attributes to set label text. Inspired by portfolio/agency sites like Studio PIC.

Apr 27, 2026

ANIMATIONS

Section Transition 01

GSAP ScrollTrigger-based section transition system. Sibling sections opt into parallax, pin, or reveal modes via data attributes. Supports y offset, overlay opacity, and overlay color per section. Mobile strategy simplifies motion on smaller screens.

Apr 27, 2026

ANIMATIONS

Text Reveal 01

GSAP SplitText-based text reveal system. Text elements opt in with data-reveal-01 and split into lines, words, or characters. Supports load-time reveals, scroll-triggered reveals, scrubbed scroll reveals, and manual split-only mode for custom timelines. Per-element overrides for duration, stagger, delay, ease, and replay behavior.