NavigationIntermediateApril 27, 2026

Nav Agency Grid

A fixed agency-style navigation bar using a 4-column CSS grid with staggered GSAP entrance animations, current-page indicator with animated dash, scroll-direction hide/show, desktop hover dropdown, and a full-screen mobile menu with large typography.

View Full Demo →
demo.jsx
import NavAgencyGrid from "./index.jsx";
import { navAgencyGrid } from "../demo-data.js";
import styles from "./demo.module.css";

export default function NavAgencyGridDemo() {
  return (
    <div className={styles.wrapper}>
      <NavAgencyGrid {...navAgencyGrid} />
      <section className={styles.sectionLight}>
        <p className={styles.label}>[ Featured ]</p>
        <h1 className={styles.heading}>Designed to engage, built to connect</h1>
      </section>
      <section className={styles.sectionDark}>
        <p className={styles.labelLight}>[ Work ]</p>
        <h1 className={styles.headingLight}>26 projects across disciplines</h1>
      </section>
      <section className={styles.sectionLight}>
        <p className={styles.label}>[ Contact ]</p>
        <h1 className={styles.heading}>Start a conversation</h1>
      </section>
    </div>
  );
}
index.jsx
"use client";

import { useState, useEffect, useRef } from "react";
import Link from "next/link";
import gsap from "gsap";
import styles from "./styles.module.css";

export default function NavAgencyGrid({
  logo = { text: "Brand", symbol: "\u00AE", href: "/" },
  links = [],
  tagline = "",
  activePageName = "Home",
  backgroundColor = "#f0f0f0",
  menuBackgroundColor,
  textColor = "#2a2a2a",
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const [hidden, setHidden] = useState(false);
  const containerRef = useRef(null);
  const logoRef = useRef(null);
  const dashRef = useRef(null);
  const nameRef = useRef(null);
  const linkRefs = useRef([]);
  const menuBtnRef = useRef(null);
  const bgRef = useRef(null);
  const taglineRef = useRef(null);
  const menuItemRefs = useRef([]);
  const lastScrollY = useRef(0);
  const menuTl = useRef(null);
  const hasBeenOpened = useRef(false);

  // Entrance animations
  useEffect(() => {
    const ctx = gsap.context(() => {
      if (logoRef.current) {
        gsap.fromTo(
          logoRef.current,
          { yPercent: 110 },
          { yPercent: 0, duration: 0.9, ease: "power3.out", delay: 0.65 }
        );
      }
      if (dashRef.current) {
        gsap.fromTo(
          dashRef.current,
          { width: 0 },
          { width: "auto", duration: 0.6, ease: "power2.out", delay: 1.2 }
        );
      }
      if (nameRef.current) {
        gsap.fromTo(
          nameRef.current,
          { yPercent: 121 },
          { yPercent: 0, duration: 0.7, ease: "power3.out", delay: 1.3 }
        );
      }
      linkRefs.current.forEach((el, i) => {
        if (el) {
          gsap.fromTo(
            el,
            { yPercent: 110 },
            { yPercent: 0, duration: 0.9, ease: "power3.out", delay: 0.7 + i * 0.05 }
          );
        }
      });
      if (menuBtnRef.current) {
        gsap.fromTo(
          menuBtnRef.current,
          { yPercent: 120 },
          { yPercent: 0, duration: 0.9, ease: "power3.out", delay: 0.7 }
        );
      }
    });
    return () => ctx.revert();
  }, []);

  // Scroll hide/show
  useEffect(() => {
    function onScroll() {
      const y = window.scrollY;
      if (y > lastScrollY.current && y > 80) setHidden(true);
      else setHidden(false);
      lastScrollY.current = y;
    }
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  // Lock body scroll when mobile menu is open
  useEffect(() => {
    document.body.style.overflow = menuOpen ? "hidden" : "";
    return () => {
      document.body.style.overflow = "";
    };
  }, [menuOpen]);

  // Menu open/close GSAP animation
  useEffect(() => {
    const isMobile = window.matchMedia("(max-width: 768px)").matches;
    if (!bgRef.current || !isMobile) return;

    if (menuTl.current) menuTl.current.kill();
    const tl = gsap.timeline();
    menuTl.current = tl;
    const items = menuItemRefs.current.filter(Boolean);

    if (menuOpen) {
      hasBeenOpened.current = true;
      gsap.set(items, { y: 40, opacity: 0 });
      if (taglineRef.current) gsap.set(taglineRef.current, { y: 20, opacity: 0 });

      tl.to(bgRef.current, {
        y: 0,
        duration: 0.55,
        ease: "power3.inOut",
      }).to(
        items,
        { y: 0, opacity: 1, stagger: 0.04, duration: 0.45, ease: "power3.out" },
        "-=0.2"
      );
      if (taglineRef.current) {
        tl.to(
          taglineRef.current,
          { y: 0, opacity: 0.5, duration: 0.4, ease: "power2.out" },
          "-=0.25"
        );
      }
    } else if (hasBeenOpened.current) {
      if (taglineRef.current) {
        tl.to(taglineRef.current, {
          y: 20,
          opacity: 0,
          duration: 0.25,
          ease: "power2.in",
        });
      }
      tl.to(
        items,
        {
          y: 30,
          opacity: 0,
          stagger: 0.02,
          duration: 0.25,
          ease: "power2.in",
        },
        taglineRef.current ? "-=0.15" : 0
      ).to(
        bgRef.current,
        { y: "-100%", duration: 0.5, ease: "power3.inOut" },
        "-=0.15"
      );
    }
  }, [menuOpen]);

  // Clean up GSAP inline styles on resize to desktop
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 768px)");
    function handleChange(e) {
      if (!e.matches) {
        menuItemRefs.current.forEach((el) => {
          if (el) gsap.set(el, { clearProps: "all" });
        });
        if (bgRef.current) gsap.set(bgRef.current, { clearProps: "all" });
        if (taglineRef.current) gsap.set(taglineRef.current, { clearProps: "all" });
        setMenuOpen(false);
        hasBeenOpened.current = false;
      }
    }
    mq.addEventListener("change", handleChange);
    return () => mq.removeEventListener("change", handleChange);
  }, []);

  return (
    <header
      ref={containerRef}
      className={styles.container}
      style={{
        backgroundColor,
        color: textColor,
        transform: hidden && !menuOpen ? "translateY(-100%)" : "translateY(0)",
      }}
    >
      <div className={styles.grid}>
        {/* Logo area */}
        <div className={styles.logoWrap}>
          <Link href={logo.href ?? "/"} className={styles.logo}>
            <span className={styles.clip}>
              <span ref={logoRef} className={styles.logoInner}>
                {logo.text}
                {logo.symbol}
              </span>
            </span>
          </Link>
          <div className={styles.nameWrap}>
            <div ref={dashRef} className={styles.dash}>
              &mdash;
            </div>
            <div className={styles.clip}>
              <div ref={nameRef} className={styles.pageName}>
                {activePageName}
              </div>
            </div>
          </div>
        </div>

        {/* Navigation */}
        <nav className={styles.nav}>
          <div
            className={`${styles.menuWrap} ${menuOpen ? styles.menuWrapOpen : ""}`}
          >
            <div
              ref={bgRef}
              className={styles.menuBg}
              style={{ backgroundColor: menuBackgroundColor || backgroundColor }}
            />
            <ul
              className={styles.menuLinks}
              style={{ pointerEvents: menuOpen ? "all" : undefined }}
            >
              {links.map((link, i) => (
                <li
                  key={link.label}
                  ref={(el) => (menuItemRefs.current[i] = el)}
                  className={`${styles.linkWrap} ${link.mobileOnly ? styles.mobileOnly : ""}`}
                >
                  {link.children ? (
                    <DropdownItem
                      link={link}
                      index={i}
                      linkRefs={linkRefs}
                      menuOpen={menuOpen}
                      onNavigate={() => setMenuOpen(false)}
                    />
                  ) : (
                    <Link
                      href={link.href ?? "#"}
                      className={`${styles.link} ${link.active ? styles.active : ""}`}
                      onClick={() => setMenuOpen(false)}
                    >
                      <span className={styles.clip}>
                        <span ref={(el) => (linkRefs.current[i] = el)}>
                          {link.label}
                        </span>
                      </span>
                      {link.count != null && (
                        <span className={`${styles.count} ${styles.mobileOnly}`}>
                          {link.count}
                        </span>
                      )}
                    </Link>
                  )}
                </li>
              ))}
            </ul>

            {tagline && (
              <div
                ref={taglineRef}
                className={`${styles.tagline} ${styles.mobileOnly}`}
              >
                <p>
                  {tagline.split("\n").map((line, i, arr) => (
                    <span key={i}>
                      {line}
                      {i < arr.length - 1 && <br />}
                    </span>
                  ))}
                </p>
              </div>
            )}
          </div>

          {/* Mobile menu button */}
          <button
            className={styles.menuBtn}
            onClick={() => setMenuOpen((o) => !o)}
            aria-label={menuOpen ? "Close menu" : "Open menu"}
          >
            <div className={styles.menuTxtWrap}>
              <div
                className={`${styles.closeTxt} ${menuOpen ? styles.closeTxtVisible : ""}`}
              >
                Close
              </div>
              <div
                className={`${styles.menuTxt} ${menuOpen ? styles.menuTxtHidden : ""}`}
              >
                <span className={styles.clip}>
                  <span ref={menuBtnRef}>Menu&nbsp;+</span>
                </span>
              </div>
            </div>
          </button>
        </nav>
      </div>
    </header>
  );
}

function DropdownItem({ link, index, linkRefs, menuOpen, onNavigate }) {
  const [open, setOpen] = useState(false);

  return (
    <>
      <div
        className={`${styles.dropdownTrigger} ${open ? styles.dropdownOpen : ""}`}
        onMouseEnter={() => setOpen(true)}
        onMouseLeave={() => setOpen(false)}
      >
        <button className={styles.link} type="button">
          <span className={styles.clip}>
            <span ref={(el) => (linkRefs.current[index] = el)}>
              {link.label}
            </span>
          </span>
        </button>
        <div
          className={`${styles.dropdown} ${open ? styles.dropdownVisible : ""}`}
        >
          {link.children.map((child) => (
            <Link
              key={child.label}
              href={child.href ?? "#"}
              className={styles.dropdownLink}
              onClick={() => {
                setOpen(false);
                onNavigate();
              }}
            >
              {child.label}
            </Link>
          ))}
        </div>
      </div>

      {/* Mobile sub-links (always visible in mobile menu) */}
      <div className={styles.mobileSubLinks}>
        {link.children.map((child) => (
          <Link
            key={child.label}
            href={child.href ?? "#"}
            className={styles.mobileSubLink}
            onClick={onNavigate}
          >
            {child.label}
          </Link>
        ))}
      </div>
    </>
  );
}
demo.module.css
.wrapper {
  background: #f0f0f0;
  min-height: 300vh;
  color: #2a2a2a;
}

.sectionLight {
  min-height: 100vh;
  background: #f0f0f0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 6rem 2rem 4rem;
}

.sectionDark {
  min-height: 100vh;
  background: #2a2a2a;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 4rem 2rem;
}

.label {
  font-size: 0.7rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  margin: 0 0 1rem;
  color: #888;
}

.labelLight {
  composes: label;
  color: #777;
}

.heading {
  font-size: clamp(1.75rem, 4vw, 3rem);
  font-weight: 400;
  letter-spacing: -0.01em;
  line-height: 1.15;
  margin: 0;
  max-width: 600px;
  color: #2a2a2a;
}

.headingLight {
  composes: heading;
  color: #f0f0f0;
}
styles.module.css
/* ========= Container ========= */
.container {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: 56px;
  padding: 0 2vw;
  z-index: 1000;
  transition: transform 1s cubic-bezier(0.215, 0.61, 0.355, 1);
}

/* ========= Grid ========= */
.grid {
  position: relative;
  height: 100%;
  display: grid;
  align-items: center;
  justify-items: flex-start;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  column-gap: 2vw;
}

/* ========= Overflow clip helper ========= */
.clip {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  line-height: 1.1;
}

/* ========= Logo ========= */
.logoWrap {
  display: flex;
  align-items: center;
  white-space: nowrap;
}

.logo {
  text-decoration: none;
  color: inherit;
  font-size: 0.85rem;
  font-weight: 400;
  line-height: 1;
}

.logoInner {
  display: inline-block;
}

/* ========= Page name indicator ========= */
.nameWrap {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin-left: 0.75rem;
}

.dash {
  font-size: 0.65rem;
  line-height: 1;
  overflow: hidden;
  width: 0;
  flex-shrink: 0;
}

.pageName {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  line-height: 1;
}

/* ========= Nav ========= */
.nav {
  grid-column: 2 / -1;
  justify-self: end;
  display: flex;
  align-items: center;
  gap: 2rem;
  height: 100%;
}

.menuWrap {
  position: relative;
}

/* ========= Link list ========= */
.menuLinks {
  display: flex;
  align-items: center;
  gap: 1.8rem;
  list-style: none;
  margin: 0;
  padding: 0;
  pointer-events: all;
}

.linkWrap {
  position: relative;
}

.link {
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  color: inherit;
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  background: none;
  border: none;
  cursor: pointer;
  font-family: inherit;
  padding: 0;
  line-height: 1;
}

.link:hover {
  opacity: 0.6;
}

.active {
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
}

/* ========= Count badges ========= */
.count {
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}

/* ========= Insights dropdown (desktop) ========= */
.dropdownTrigger {
  position: relative;
}

.dropdown {
  position: absolute;
  top: calc(100% + 12px);
  left: 50%;
  transform: translateX(-50%);
  background: inherit;
  min-width: 180px;
  padding: 0.75rem 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}

.dropdownVisible {
  opacity: 1;
  pointer-events: all;
}

.dropdownLink {
  display: block;
  padding: 0.4rem 1rem;
  font-size: 0.62rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  color: inherit;
  white-space: nowrap;
  transition: opacity 0.2s;
}

.dropdownLink:hover {
  opacity: 0.6;
}

/* ========= Mobile sub-links (hidden on desktop) ========= */
.mobileSubLinks {
  display: none;
}

.mobileSubLink {
  display: block;
  padding: 0.5rem 0 0.5rem 1rem;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  text-decoration: none;
  color: inherit;
}

/* ========= Tagline ========= */
.tagline {
  font-size: 0.6rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  line-height: 1.6;
  opacity: 0.5;
  padding-top: 2rem;
}

/* ========= Mobile menu background (hidden on desktop) ========= */
.menuBg {
  display: none;
}

/* ========= Mobile menu button ========= */
.menuBtn {
  display: none;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  color: inherit;
  font-family: inherit;
  font-size: 0.65rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  line-height: 1;
  position: relative;
  height: 100%;
}

.menuTxtWrap {
  position: relative;
  overflow: hidden;
}

.closeTxt {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transform: translateY(-100%);
  transition: transform 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
}

.closeTxtVisible {
  transform: translateY(0);
}

.menuTxt {
  transition: transform 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
}

.menuTxtHidden {
  transform: translateY(120%);
}

/* ========= Mobile-only helper ========= */
.mobileOnly {
  display: none;
}

/* ========= Responsive ========= */
@media (max-width: 768px) {
  .container {
    height: 52px;
  }

  .grid {
    grid-template-columns: 1fr auto;
    column-gap: 0;
  }

  .nameWrap {
    display: none;
  }

  .logoWrap {
    position: relative;
    z-index: 2;
  }

  .nav {
    grid-column: auto;
    justify-self: end;
  }

  .menuBtn {
    display: flex;
    position: relative;
    z-index: 2;
  }

  .mobileOnly {
    display: block;
  }

  /* Mobile menu fullscreen wrapper */
  .menuWrap {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 80px 4vw 2rem;
    pointer-events: none;
  }

  .menuWrapOpen {
    pointer-events: all;
  }

  /* Mobile menu background — slides down via GSAP */
  .menuBg {
    display: block;
    position: absolute;
    inset: 0;
    z-index: -1;
    transform: translateY(-100%);
    will-change: transform;
  }

  /* Mobile link list */
  .menuLinks {
    flex-direction: column;
    align-items: flex-start;
    gap: 0;
    pointer-events: none;
  }

  .menuWrapOpen .menuLinks {
    pointer-events: all;
  }

  .linkWrap {
    width: 100%;
    opacity: 0;
  }

  .link {
    font-size: 2rem;
    text-transform: none;
    letter-spacing: -0.01em;
    font-weight: 400;
    padding: 0.35rem 0;
    width: 100%;
    justify-content: space-between;
  }

  .link:hover {
    opacity: 1;
  }

  .count {
    font-size: 0.85rem;
    opacity: 0.4;
  }

  .active {
    text-decoration: none;
  }

  /* Dropdown — disable on mobile */
  .dropdownTrigger {
    width: 100%;
  }

  .dropdownTrigger .link {
    width: 100%;
  }

  .dropdown {
    display: none;
  }

  .mobileSubLinks {
    display: flex;
    flex-direction: column;
    padding-bottom: 0.25rem;
  }

  .mobileSubLink {
    font-size: 0.85rem;
    padding: 0.3rem 0 0.3rem 0.25rem;
    opacity: 0.5;
  }

  .tagline {
    display: block;
    padding-top: 3rem;
    font-size: 0.7rem;
    opacity: 0;
  }
}
  • gsap

May 4, 2026

NAVIGATION

Nav Slide Panel

A minimal header that slides in from the top on load. Desktop shows logo, nav links, language switcher, and underlined contact CTA. Mobile collapses to logo + menu toggle with a full-screen white panel featuring large stagger-animated nav links, office locations, language selector, and copyright.

Apr 24, 2026

NAVIGATION

Nav Glassmorphic

A floating glassmorphic navigation bar with frosted semi-transparent background, centered nav links, locale selector, and outlined CTA button. Collapses to logo + hamburger on mobile with an expanding panel featuring nav links, social links, and email.

Apr 10, 2026

NAVIGATION

Nav Minimal

A clean top navigation bar with logo (image or text), centered nav links with hover-reveal submenu row, locale switcher, and account/cart icons. Collapses to logo + cart + hamburger on mobile.