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 →Preview
DashDigital®
—
Home
Source
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}>
—
</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 +</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;
}
}
Dependencies
gsap