NavigationIntermediateApril 9, 2026
Nav Menu Panel
Fixed header bar with a centered menu toggle that expands a full-width slide-down panel. Panel contains large flip-text nav links, contact info, and image cards. CSS grid accordion for the open/close animation, flip-text hover effect on links, green accent square on active/hover state.
View Full Demo →Preview
Source
demo.jsx
import NavMenuPanel from "./index.jsx";
import styles from "./demo.module.css";
export default function NavMenuPanelDemo() {
return (
<div className={styles.demo}>
<NavMenuPanel />
<div className={styles.hero}>
<p className={styles.label}>Creative Studio — 2024</p>
<h1 className={styles.heading}>
Building<br />Experiences
</h1>
<p className={styles.sub}>Web Design & Development</p>
</div>
<div className={styles.footer}>
<span>scroll to explore</span>
<span>↓</span>
</div>
</div>
);
}
index.jsx
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import styles from "./styles.module.css";
const DEFAULT_NAV_LINKS = [
{ label: "Home", href: "/" },
{ label: "Work", href: "/work" },
{ label: "Pricing", href: "/pricing" },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
];
const DEFAULT_CONTACT_INFO = {
general: "contact@No-5.studio",
socials: [
{ label: "LinkedIn", handle: "@No-5.studio" },
{ label: "Github", handle: "@kevindaviis" },
],
notices: ["Accepting projects."],
};
const DEFAULT_IMAGE_CARDS = [
{ src: "/demo-assets/models/model0.png", caption: "About the Studio" },
{ src: "/demo-assets/models/model1.png", caption: "Featured Project" },
];
export default function NavMenuPanel({
navLinks = DEFAULT_NAV_LINKS,
contactInfo = DEFAULT_CONTACT_INFO,
imageCards = DEFAULT_IMAGE_CARDS,
}) {
const [isOpen, setIsOpen] = useState(false);
const pathname = usePathname();
useEffect(() => {
const handleKey = (e) => {
if (e.key === "Escape" && isOpen) setIsOpen(false);
};
document.addEventListener("keydown", handleKey);
return () => document.removeEventListener("keydown", handleKey);
}, [isOpen]);
useEffect(() => {
document.body.style.overflow = isOpen ? "hidden" : "";
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
return (
<div className={styles.root}>
<div
className={`${styles.overlay} ${isOpen ? styles.overlayVisible : ""}`}
onClick={() => setIsOpen(false)}
aria-hidden="true"
/>
<div className={styles.navSection}>
<header className={styles.header}>
{/* ── Bar ── */}
<div className={styles.bar}>
<Link href="/" className={styles.logo}>N°5</Link>
<button
className={styles.menuToggle}
onClick={() => setIsOpen(!isOpen)}
aria-label={isOpen ? "Close menu" : "Open menu"}
>
{isOpen ? (
<>
<span className={styles.toggleLabel}>Close</span>
<span className={styles.toggleIcon}>✕</span>
</>
) : (
<>
<span className={styles.toggleLabel}>Menu</span>
<span className={styles.toggleIcon}>≡</span>
</>
)}
</button>
<div className={styles.ctaWrapper}>
<a href="/contact" className={styles.ctaLabel}>
<span className={styles.ctaIconInner}>
<span className={styles.ctaIconDefault}>Let's work together</span>
<span className={styles.ctaIconHover}>Let's work together</span>
</span>
</a>
<span className={styles.ctaIcon}>
<span className={styles.ctaIconInner}>
<span className={styles.ctaIconDefault}>+</span>
<span className={styles.ctaIconHover}>+</span>
</span>
</span>
</div>
</div>
{/* ── Panel ── */}
<div
className={`${styles.panel} ${isOpen ? styles.panelOpen : ""}`}
aria-hidden={!isOpen}
>
<div className={styles.panelClip}>
<div className={styles.panelInner}>
{/* Nav links */}
<nav className={styles.navCol}>
<ul className={styles.navList}>
{navLinks.map((link, i) => {
const isActive = pathname === link.href;
return (
<li
key={link.href}
className={styles.navItem}
style={{ transitionDelay: `${i * 0.05}s` }}
>
<Link
href={link.href}
className={`${styles.navLink} ${isActive ? styles.navLinkActive : ""}`}
onClick={() => setIsOpen(false)}
>
<span className={styles.activeSquare} aria-hidden="true" />
<span className={styles.navLabelInner}>
<span className={styles.navLabelDefault}>{link.label}</span>
<span className={styles.navLabelHover}>{link.label}</span>
</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Contact info */}
<div className={styles.contactCol}>
<p className={styles.contactTagline}>Contact</p>
<a href={`mailto:${contactInfo.general}`} className={styles.contactEmail}>
{contactInfo.general}
</a>
<div className={styles.contactSocials}>
{contactInfo.socials.map((s) => (
<p key={s.label} className={styles.contactSocialItem}>
{s.label}: {s.handle}
</p>
))}
</div>
<div className={styles.notices}>
<p className={styles.noticesTagline}>Working Globally</p>
{contactInfo.notices.map((notice) => (
<div key={notice} className={styles.noticeRow}>
<span className={styles.noticeSquare} aria-hidden="true" />
<p className={styles.noticeText}>{notice}</p>
</div>
))}
</div>
</div>
{/* Image cards */}
<div className={styles.imageCol}>
{imageCards.map((card) => (
<div key={card.caption} className={styles.imageCard}>
<div className={styles.imageWrap}>
<img src={card.src} alt={card.caption} className={styles.cardImg} />
</div>
<p className={styles.imageCaption}>{card.caption}</p>
</div>
))}
</div>
</div>
</div>
</div>
</header>
</div>
</div>
);
}
demo.module.css
.demo {
position: relative;
min-height: 100vh;
background: #0e0e0e;
display: flex;
flex-direction: column;
/* Contains fixed-position children (NavMenuPanel) within this element */
transform: translateZ(0);
}
.hero {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
padding: 8rem 4rem 4rem;
gap: 1.25rem;
}
.label {
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.35);
}
.heading {
font-size: clamp(3rem, 8vw, 7rem);
font-weight: 500;
letter-spacing: -0.03em;
line-height: 0.95;
color: #ffffff;
}
.sub {
font-size: 1rem;
font-weight: 400;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.4);
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem 4rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.25);
}
styles.module.css
/* Local tokens — component is self-contained */
.root {
--z-nav: 1000;
--color-white: #ffffff;
--color-gray: rgba(255, 255, 255, 0.45);
--weight-large: 600;
--font-body: 1rem;
--font-body-sm: 0.875rem;
--font-body-2xl: 1.5rem;
--font-tagline: 0.75rem;
--space-2xs: 0.375rem;
--space-xs: 0.5rem;
--space-s: 0.75rem;
--space-m: 1rem;
--space-xl: 1.5rem;
--space-2xl: 2.5rem;
--space-4xl: 3.5rem;
--space-6xl: 4.5rem;
--space-8xl: 6rem;
}
.navSection {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: var(--z-nav);
padding: var(--space-xl) var(--space-4xl);
}
.header {
background-color: #1f1f20;
color: var(--color-white);
overflow: hidden;
}
/* ── Bar ── */
.bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-s) var(--space-6xl);
position: relative;
}
.logo {
font-size: 2.25rem;
font-weight: var(--weight-bold);
color: var(--color-white);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.menuToggle {
display: flex;
align-items: center;
gap: var(--space-xs);
background: transparent;
border: #1f1f20;
color: var(--color-white);
padding: var(--space-xs) var(--space-xl);
font-size: var(--font-body-2xl);
font-weight: var(--weight-large);
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
border-radius: 0.25rem;
transition: border-color 200ms ease;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.menuToggle:hover {
border-color: rgba(255, 255, 255, 0.6);
}
.toggleLabel {
font-size: var(--font-body);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.toggleIcon {
font-size: var(--font-body);
line-height: 1;
}
.ctaWrapper {
display: flex;
align-items: center;
flex-direction: row;
gap: var(--space-xs);
}
.ctaLabel {
display: inline-flex;
align-items: center;
background-color: #1a6b3c;
color: var(--color-white);
padding: var(--space-xs) var(--space-xl);
font-size: var(--font-body-sm);
font-weight: var(--weight-medium);
letter-spacing: 0.06em;
text-transform: uppercase;
border-radius: 0.25rem;
transition: background-color 200ms ease;
overflow: hidden;
}
.ctaWrapper:hover .ctaLabel {
background-color: #155630;
}
.ctaIcon {
display: inline-flex;
align-items: center;
justify-content: center;
background-color: #1a6b3c;
color: var(--color-white);
width: 2.5rem;
height: 2.15rem;
border-radius: 0.25rem;
overflow: hidden;
transition: background-color 200ms ease;
}
.ctaWrapper:hover .ctaIcon {
background-color: #155630;
}
.ctaIconInner {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.ctaIconDefault {
display: flex;
align-items: center;
justify-content: center;
font-size: inherit;
transition: transform 500ms cubic-bezier(0.65, 0, 0, 1);
transform: translateY(0%);
}
.ctaWrapper:hover .ctaIconDefault {
transform: translateY(-150%);
}
.ctaIconHover {
display: flex;
align-items: center;
justify-content: center;
font-size: inherit;
position: absolute;
transition: transform 500ms cubic-bezier(0.65, 0, 0, 1);
transform: translateY(150%);
transition-delay: 100ms;
}
.ctaWrapper:hover .ctaIconHover {
transform: translateY(0%);
transition-delay: 100ms;
}
/* ── Overlay ── */
.overlay {
position: fixed;
inset: 0;
background-color: #1f1f20;
opacity: 0;
visibility: hidden;
z-index: calc(var(--z-nav) - 1);
transition: opacity 0.5s ease, visibility 0.5s ease;
}
.overlayVisible {
opacity: 0.6;
visibility: visible;
}
/* ── Panel ── */
.panel {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.6s cubic-bezier(0.625, 0.05, 0, 1);
}
.panelOpen {
grid-template-rows: 1fr;
}
.panelClip {
overflow: hidden;
min-height: 0;
}
.panelInner {
display: grid;
grid-template-columns: 1fr 1fr 2fr;
gap: var(--space-6xl);
padding: var(--space-6xl) var(--space-8xl);
border-top: 6px solid rgba(0, 0, 0, 0.25);
background-color: #1f1f20;
}
/* ── Nav column ── */
.navList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0;
}
.navItem {
overflow: hidden;
}
.navLink {
display: flex;
align-items: center;
gap: var(--space-s);
padding-block: var(--space-xs);
text-decoration: none;
}
/* Square — hidden by default, visible on active or hover */
.activeSquare {
display: inline-block;
width: 1.25rem;
height: 1.25rem;
background-color: #1a6b3c;
flex-shrink: 0;
opacity: 0;
transform: scale(0.5);
transition: opacity 300ms ease, transform 300ms ease;
}
.navLink:hover .activeSquare,
.navLinkActive .activeSquare {
opacity: 1;
transform: scale(1);
}
/* Label inner wrapper — clips the two text layers */
.navLabelInner {
position: relative;
overflow: hidden;
display: block;
}
/* Default text — white, sits at translateY(0), slides up on hover */
.navLabelDefault {
display: block;
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: var(--weight-medium);
line-height: 1.1;
color: var(--color-white);
transition: transform 500ms cubic-bezier(0.65, 0, 0, 1);
transform: translateY(0%);
}
.navLink:hover .navLabelDefault,
.navLinkActive .navLabelDefault {
transform: translateY(-100%);
}
/* Hover text — green, starts below, slides up into view */
.navLabelHover {
display: block;
position: absolute;
top: 0;
left: 0;
font-size: clamp(2rem, 4vw, 3.5rem);
font-weight: var(--weight-medium);
line-height: 1.1;
color: #1a6b3c;
transition: transform 500ms cubic-bezier(0.65, 0, 0, 1);
transform: translateY(100%);
}
.navLink:hover .navLabelHover,
.navLinkActive .navLabelHover {
transform: translateY(0%);
}
/* ── Contact column ── */
.contactCol {
display: flex;
flex-direction: column;
gap: var(--space-m);
padding-top: var(--space-xs);
}
.contactTagline {
font-size: var(--font-body);
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-gray);
}
.contactEmail {
font-size: var(--font-body);
color: var(--color-white);
text-decoration: none;
transition: color 200ms ease;
}
.contactSocials {
display: flex;
flex-direction: column;
gap: var(--space-2xs);
padding-top: var(--space-m);
}
.contactSocialItem {
font-size: var(--font-body-sm);
color: var(--color-gray);
}
.notices {
display: flex;
flex-direction: column;
gap: var(--space-m);
margin-top: auto;
}
.noticesTagline {
font-size: var(--font-tagline);
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-gray);
}
.noticeRow {
display: flex;
align-items: flex-start;
gap: var(--space-s);
}
.noticeSquare {
display: inline-block;
width: 0.625rem;
height: 0.625rem;
background-color: #1a6b3c;
flex-shrink: 0;
margin-top: 0.25rem;
}
.noticeText {
font-size: var(--font-body-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--color-white);
}
/* ── Image column ── */
.imageCol {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-m);
align-items: start;
}
.imageCard {
display: flex;
flex-direction: column;
gap: var(--space-s);
}
.imageWrap {
position: relative;
width: 100%;
aspect-ratio: 3 / 4;
overflow: hidden;
background-color: rgba(255, 255, 255, 0.08);
}
.cardImg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.imageCaption {
font-size: var(--font-tagline);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-gray);
}
/* ── Tablet ── */
@media (max-width: 1023px) {
.navSection {
padding: 0;
}
.bar {
padding: var(--space-s) var(--space-2xl);
}
.menuToggle {
position: static;
transform: none;
}
.ctaWrapper {
display: none;
}
.panelInner {
grid-template-columns: 1fr;
padding: var(--space-4xl) var(--space-2xl);
gap: var(--space-4xl);
}
.imageCol {
display: none;
}
}
/* ── Mobile ── */
@media (max-width: 767px) {
.bar {
padding: var(--space-s) var(--space-m);
}
.panelInner {
padding: var(--space-4xl) var(--space-m);
}
}

