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 →Preview
Source
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 & 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;
}
}
Dependencies
gsap