NavigationIntermediateMay 4, 2026

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.

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

export default function NavSlidePanelDemo() {
  return (
    <div className={styles.wrapper}>
      <NavSlidePanel {...navSlidePanel} />
      <section className={styles.sectionWhite}>
        <h1 className={styles.heading}>We Grow<br />Brands</h1>
      </section>
      <section className={styles.sectionLight}>
        <p className={styles.label}>[ About ]</p>
        <h1 className={styles.subheading}>A brand development firm that works in thought and in action.</h1>
      </section>
      <section className={styles.sectionWhite}>
        <p className={styles.label}>[ Work ]</p>
        <h1 className={styles.subheading}>Selected projects across eras &amp; disciplines</h1>
      </section>
    </div>
  );
}
index.jsx
"use client";

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

export default function NavSlidePanel({
  logo = { text: "Brand", href: "/" },
  links = [],
  offices = [],
  locales = [],
  copyright = "",
  ctaLabel = "Contact",
  ctaHref = "#",
}) {
  const [menuOpen, setMenuOpen] = useState(false);
  const [loaded, setLoaded] = useState(false);
  const headerRef = useRef(null);
  const menuRef = useRef(null);
  const staggerRefs = useRef([]);
  const menuTl = useRef(null);

  // Entrance slide-down
  useEffect(() => {
    const t = setTimeout(() => setLoaded(true), 100);
    return () => clearTimeout(t);
  }, []);

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

  // Menu open/close animation
  useEffect(() => {
    if (!menuRef.current) return;
    if (menuTl.current) menuTl.current.kill();

    const items = staggerRefs.current.filter(Boolean);
    const tl = gsap.timeline();
    menuTl.current = tl;

    if (menuOpen) {
      tl.to(menuRef.current, {
        "--menu-scale": 1,
        duration: 0.45,
        ease: "power3.out",
      }).fromTo(
        items,
        { y: 20, opacity: 0 },
        { y: 0, opacity: 1, stagger: 0.04, duration: 0.4, ease: "power3.out" },
        "-=0.2"
      );
    } else {
      tl.to(items, {
        y: 20,
        opacity: 0,
        stagger: 0.02,
        duration: 0.25,
        ease: "power2.in",
      }).to(
        menuRef.current,
        { "--menu-scale": 0, duration: 0.4, ease: "power3.inOut" },
        "-=0.1"
      );
    }
  }, [menuOpen]);

  // Clean up on resize to desktop
  useEffect(() => {
    const mq = window.matchMedia("(min-width: 1024px)");
    function handleChange(e) {
      if (e.matches && menuOpen) {
        setMenuOpen(false);
        staggerRefs.current.forEach((el) => {
          if (el) gsap.set(el, { clearProps: "all" });
        });
        if (menuRef.current) {
          menuRef.current.style.removeProperty("--menu-scale");
        }
      }
    }
    mq.addEventListener("change", handleChange);
    return () => mq.removeEventListener("change", handleChange);
  }, [menuOpen]);

  const addStaggerRef = useCallback((el, i) => {
    staggerRefs.current[i] = el;
  }, []);

  let staggerIndex = 0;

  return (
    <header
      ref={headerRef}
      className={`${styles.header} ${loaded ? styles.loaded : ""}`}
    >
      {/* Top bar */}
      <div className={styles.bar}>
        {/* Logo */}
        <div className={styles.logoCol}>
          <Link href={logo.href ?? "/"} className={styles.logo}>
            {logo.svg ? (
              <span
                className={styles.logoSvg}
                dangerouslySetInnerHTML={{ __html: logo.svg }}
              />
            ) : (
              <span className={styles.logoText}>{logo.text}</span>
            )}
          </Link>
        </div>

        {/* Desktop nav */}
        <nav className={styles.desktopNav}>
          <ul className={styles.navList}>
            {links.map((link) => (
              <li key={link.label} className={styles.navItem}>
                <Link href={link.href ?? "#"} className={styles.navLink}>
                  {link.label}
                </Link>
              </li>
            ))}
            <li className={styles.navItemLast}>
              {locales.length > 0 && (
                <ul className={styles.langList}>
                  {locales.map((loc) => (
                    <li key={loc.label}>
                      <Link
                        href={loc.href ?? "#"}
                        className={`${styles.langLink} ${loc.active ? styles.langActive : ""}`}
                      >
                        {loc.label}
                      </Link>
                    </li>
                  ))}
                </ul>
              )}
              <Link href={ctaHref} className={styles.ctaLink}>
                {ctaLabel}
              </Link>
            </li>
          </ul>
        </nav>

        {/* Mobile menu toggle */}
        <button
          className={styles.menuBtn}
          onClick={() => setMenuOpen((o) => !o)}
          aria-label={menuOpen ? "Close menu" : "Open menu"}
        >
          <span className={styles.menuBtnText}>
            <span className={`${styles.menuLabel} ${menuOpen ? styles.menuLabelHidden : ""}`}>
              Menu
            </span>
            <span className={`${styles.closeLabel} ${menuOpen ? styles.closeLabelVisible : ""}`}>
              Close
            </span>
          </span>
        </button>
      </div>

      {/* Full-screen mobile menu */}
      <div
        ref={menuRef}
        className={`${styles.menu} ${menuOpen ? styles.menuOpen : ""}`}
      >
        <div className={styles.menuScroller}>
          {/* Nav links */}
          <div className={styles.menuNavSection}>
            <ul className={styles.menuNavList}>
              {links.map((link) => {
                const i = staggerIndex++;
                return (
                  <li
                    key={link.label}
                    ref={(el) => addStaggerRef(el, i)}
                    className={styles.menuNavItem}
                  >
                    <Link
                      href={link.href ?? "#"}
                      className={styles.menuNavLink}
                      onClick={() => setMenuOpen(false)}
                    >
                      {link.label}
                    </Link>
                  </li>
                );
              })}
              <li
                ref={(el) => addStaggerRef(el, staggerIndex++)}
                className={styles.menuNavItem}
              >
                <Link
                  href={ctaHref}
                  className={styles.menuNavLink}
                  onClick={() => setMenuOpen(false)}
                >
                  {ctaLabel}
                </Link>
              </li>
            </ul>
          </div>

          {/* Offices */}
          {offices.length > 0 && (
            <div className={styles.menuOfficesSection}>
              <p
                ref={(el) => addStaggerRef(el, staggerIndex++)}
                className={styles.officesLabel}
              >
                Offices
              </p>
              <ul className={styles.officesList}>
                {offices.map((office) => {
                  const i = staggerIndex++;
                  return (
                    <li
                      key={office.label}
                      ref={(el) => addStaggerRef(el, i)}
                    >
                      <Link
                        href={office.href ?? "#"}
                        className={styles.officeLink}
                        target={office.href?.startsWith("http") ? "_blank" : undefined}
                        rel={office.href?.startsWith("http") ? "noopener noreferrer" : undefined}
                      >
                        {office.label}
                      </Link>
                    </li>
                  );
                })}
              </ul>
            </div>
          )}

          {/* Bottom: locale + copyright */}
          <div className={styles.menuBottom}>
            {locales.length > 0 && (
              <div ref={(el) => addStaggerRef(el, staggerIndex++)}>
                <ul className={styles.langList}>
                  {locales.map((loc) => (
                    <li key={loc.label}>
                      <Link
                        href={loc.href ?? "#"}
                        className={`${styles.langLink} ${loc.active ? styles.langActive : ""}`}
                      >
                        {loc.label}
                      </Link>
                    </li>
                  ))}
                </ul>
              </div>
            )}
            {copyright && (
              <div
                ref={(el) => addStaggerRef(el, staggerIndex++)}
                className={styles.copyright}
              >
                {copyright}
              </div>
            )}
          </div>
        </div>
      </div>
    </header>
  );
}
demo.module.css
.wrapper {
  background: #fff;
  min-height: 300vh;
  color: #000;
  font-family: inherit;
}

.sectionWhite {
  min-height: 100vh;
  background: #fff;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  padding: 6rem 2.5rem 4rem;
}

.sectionLight {
  min-height: 100vh;
  background: #f5f5f5;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: center;
  padding: 4rem 2.5rem;
}

.heading {
  font-size: clamp(3rem, 8vw, 7rem);
  font-weight: 400;
  letter-spacing: -0.03em;
  line-height: 1;
  margin: 0;
  color: #000;
}

.subheading {
  font-size: clamp(1.5rem, 3vw, 2.5rem);
  font-weight: 400;
  letter-spacing: -0.01em;
  line-height: 1.2;
  margin: 0;
  max-width: 600px;
  color: #000;
}

.label {
  font-size: 0.75rem;
  font-weight: 400;
  margin: 0 0 1rem;
  color: #888;
}
styles.module.css
/* ========= Header ========= */
.header {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 50;
  transform: translateY(-100%);
  will-change: transform;
  transition: background-color 1.2s cubic-bezier(0.3, 0.86, 0.36, 0.95),
    color 1.2s cubic-bezier(0.3, 0.86, 0.36, 0.95),
    transform 1.2s cubic-bezier(0.3, 0.86, 0.36, 0.95);
  font-family: inherit;
}

.loaded {
  transform: translateY(0);
}

/* ========= Top bar ========= */
.bar {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  padding: 1.5rem 2.5rem;
  position: relative;
  z-index: 2;
  overflow: hidden;
}

/* ========= Logo ========= */
.logoCol {
  flex-shrink: 0;
}

.logo {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  color: inherit;
}

.logoSvg {
  display: inline-flex;
  align-items: center;
}

.logoSvg svg {
  width: 6.3rem;
  height: auto;
}

.logoText {
  font-size: 1.5rem;
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1;
}

/* ========= Desktop nav ========= */
.desktopNav {
  display: block;
}

.navList {
  display: flex;
  align-items: baseline;
  gap: 3rem;
  list-style: none;
  margin: 0;
  padding: 0;
  flex-wrap: wrap;
}

.navItem {
  /* default item */
}

.navItemLast {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  margin-left: auto;
}

.navLink {
  font-size: 1rem;
  font-weight: 400;
  text-decoration: none;
  color: inherit;
  line-height: 1.4;
  transition: opacity 0.3s;
}

.navLink:hover {
  opacity: 0.5;
}

/* ========= CTA link (Contact) ========= */
.ctaLink {
  font-size: 1rem;
  font-weight: 400;
  text-decoration: underline;
  text-underline-offset: 4px;
  text-decoration-thickness: 1px;
  color: inherit;
  transition: opacity 0.3s;
}

.ctaLink:hover {
  opacity: 0.5;
}

/* ========= Language list ========= */
.langList {
  display: flex;
  align-items: baseline;
  gap: 0;
  list-style: none;
  margin: 0;
  padding: 0;
}

.langList li + li::before {
  content: "|";
  margin: 0 0.35rem;
  opacity: 0.4;
  font-size: 0.85rem;
}

.langLink {
  font-size: 0.85rem;
  text-decoration: none;
  color: inherit;
  opacity: 0.4;
  transition: opacity 0.3s;
}

.langLink:hover {
  opacity: 1;
}

.langActive {
  opacity: 1;
  pointer-events: none;
}

/* ========= Mobile menu button ========= */
.menuBtn {
  display: none;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  color: inherit;
  font-family: inherit;
  font-size: 1rem;
  font-weight: 400;
  line-height: 1;
  position: relative;
  z-index: 2;
}

.menuBtnText {
  position: relative;
  display: block;
  overflow: hidden;
  height: 1.4em;
}

.menuLabel,
.closeLabel {
  display: block;
  transition: transform 0.4s cubic-bezier(0.3, 0.86, 0.36, 0.95);
}

.menuLabel {
  transform: translateY(0);
}

.menuLabelHidden {
  transform: translateY(-100%);
}

.closeLabel {
  position: absolute;
  top: 0;
  left: 0;
  transform: translateY(100%);
}

.closeLabelVisible {
  transform: translateY(0);
}

/* ========= Full-screen menu ========= */
.menu {
  --menu-scale: 0;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  pointer-events: none;
  z-index: 1;
}

.menu::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 100%;
  background: #fff;
  transform-origin: top;
  transform: scaleY(var(--menu-scale, 0));
  transition: none;
}

.menuOpen {
  pointer-events: all;
}

.menuScroller {
  position: relative;
  height: 100%;
  padding: 6rem 2.5rem 2rem;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}

/* ========= Menu nav section ========= */
.menuNavSection {
  padding-top: 2rem;
}

.menuNavList {
  display: grid;
  gap: 1.2rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.menuNavItem {
  opacity: 0;
  transform: translateY(20px);
}

.menuNavLink {
  font-size: 3.8rem;
  font-weight: 400;
  line-height: 1;
  text-decoration: none;
  color: #000;
  transition: opacity 0.3s;
}

.menuNavLink:hover {
  opacity: 0.5;
}

/* ========= Menu offices section ========= */
.menuOfficesSection {
  padding: 2rem 0;
  flex-grow: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.officesLabel {
  font-size: 0.75rem;
  opacity: 0.4;
  margin: 0 0 2rem;
  text-transform: none;
}

.officesList {
  display: grid;
  gap: 0.5rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.officeLink {
  font-size: 2.4rem;
  font-weight: 400;
  line-height: 1.15;
  text-decoration: none;
  color: #000;
  transition: opacity 0.3s;
}

.officeLink:hover {
  opacity: 0.5;
}

/* ========= Menu bottom ========= */
.menuBottom {
  margin-top: auto;
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
}

.copyright {
  font-size: 0.75rem;
  opacity: 0.5;
}

/* ========= Responsive ========= */
@media (max-width: 1023px) {
  .bar {
    padding: 1.25rem 1.5rem;
    align-items: center;
  }

  .logoSvg svg {
    width: 4.4rem;
  }

  .desktopNav {
    display: none;
  }

  .menuBtn {
    display: block;
  }
}

@media (max-width: 480px) {
  .bar {
    padding: 1rem 1rem;
  }

  .menuScroller {
    padding: 5rem 1rem 1.5rem;
  }

  .menuNavLink {
    font-size: 2.8rem;
  }

  .officeLink {
    font-size: 1.8rem;
  }
}
  • gsap

Apr 27, 2026

NAVIGATION

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.

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.