Stacked Layers Overlay Menu — Curtain-Drop Background Stack + Clip-Path Panel Wipe + Masked Line Reveal
Goal
Build a fixed top navbar with a hamburger toggler that opens a fullscreen overlay menu. The signature effect is a three-part open sequence off a single paused GSAP timeline: (1) four stacked full-height green background layers drop down from the top like curtains (scaleY: 0 → 1, transform-origin: top) one after another with a small stagger; (2) overlapping that, the dark nav panel wipes in from top to bottom via an animated clip-path polygon; and (3) the menu links — split into masked lines — slide up from below their masks in three staggered groups. Clicking the toggler again .reverse()s the exact same timeline to close (curtains retract, panel wipes up), and the hamburger bars morph into an X via CSS. It is entirely click-driven — no scroll, no autoplay.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin SplitText. No ScrollTrigger, no smooth-scroll library. Import as:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
Layout / HTML
nav (fixed top bar, flex space-between, z-index 2)
.nav-logo > a > img (small square logo mark, top-left)
button.nav-toggler (hamburger — click target)
span (top bar)
span (bottom bar)
.nav-content (absolute overlay, pointer-events:none, z-index 1)
.nav-bg (stacked background layer 1)
.nav-bg (stacked background layer 2)
.nav-bg (stacked background layer 3)
.nav-bg (stacked background layer 4)
.nav-items (the dark clip-path panel holding the links)
.nav-items-col (col 1, narrower)
.nav-socials → 6 × a (social links)
.nav-legal → 4 × a (small legal links)
.nav-items-col (col 2, wider)
.nav-primary-links → 5 × a (big primary links)
.nav-secondary-links → 4 × a (medium secondary links)
section.hero (fullscreen background image, sits under the menu)
Use neutral, fictional labels — no real brand names:
- .nav-socials (6): Bluesky, Pinterest, YouTube, Instagram, LinkedIn, X
- .nav-legal (4): Cookie Policy, Accessibility, Data Rights, Disclosures
- .nav-primary-links (5): Home, Experiments, Latest Updates, Documentation, Community
- .nav-secondary-links (4): Playground, Build Something, Activity Feed, Profile
Styling
Fonts (Google Fonts): Onest (variable 100–900, body/links) and Google Sans (variable, optical-size + italic axes — loaded but the visible UI is essentially all Onest). body { font-family: "Onest", sans-serif; background-color: #141414; }.
Global reset: * { margin:0; padding:0; box-sizing:border-box; }.
Color tokens (all greens on near-black):
- Page background:
#141414 .nav-toggler spanbars + all link text:#fff.nav-legal atext:#318b6f(muted green).nav-itemspanel fill:#084331(deep green)- The four stacked
.nav-bglayers, in DOM order 1→4: nth-child(1):#57cea5(light mint green)nth-child(2):#063124(near-black deep green)nth-child(3):#0b5c43(dark green)nth-child(4):#21ba80(medium emerald)
Type sizes (all links: text-decoration:none; color:#fff; display:block; letter-spacing:-2%; line-height:1.1; margin-bottom:0.5rem;):
.nav-socials a:1.25rem.nav-legal a:0.9rem, color#318b6f.nav-primary-links a:3rem(the big display links).nav-secondary-links a:1.5rem.nav-logo img:40px × 40px
Structural / load-bearing CSS:
.hero:position: relative; width: 100%; height: 100svh;fullscreen background imageno-repeat 50% 50%,background-size: cover.nav:position: fixed; top: 0; width: 100%;flexjustify-content: space-between; align-items: center;padding: 1rem; z-index: 2;..nav-logo, .nav-toggler:padding: 1rem; cursor: pointer;..nav-toggler:background: none; border: none;flex column,justify-content: center; align-items: center; gap: 5px;. Eachspan:width: 40px; height: 2px; background: #fff; transition: all 0.4s ease;(two thin white bars, 5px apart).- Hamburger → X morph is pure CSS (a
transition, not GSAP), driven by an.openclass the JS toggles on.nav-toggler:
``css .nav-toggler.open span:first-child { transform: translateY(3.5px) rotate(45deg) scaleX(0.75); } .nav-toggler.open span:nth-child(2) { transform: translateY(-3.5px) rotate(-45deg) scaleX(0.75); } ``
.nav-content:position: absolute; top: 0; left: 0; width: 100%; pointer-events: none; z-index: 1;(never blocks the hero when closed)..nav-bg:position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1;transform: scaleY(0); transform-origin: top; will-change: transform; pointer-events: none;— the four layers are stacked on top of each other, each collapsed to zero height at the top edge (this is the closed state the timeline animates from)..nav-items: flex row,gap: 2rem; padding: 8rem; background-color: #084331;clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); will-change: clip-path;— the closed clip-path is a degenerate zero-height rectangle pinned at the top edge (all four points aty:0), so the panel is invisible until the timeline expands it..nav-items-col:nth-child(1):flex: 2;flex column,justify-content: space-between; gap: 2rem;(socials on top, legal on bottom)..nav-items-col:nth-child(2):flex: 4;flex row,gap: 2rem; justify-content: space-between;(primary + secondary link groups side by side).- Masked-line initial state (set in CSS, matched by SplitText output):
.nav-content a .line { position: relative; will-change: transform; transform: translateY(100%); }— every split line starts pushed down 100%, hidden below its line-mask.
GSAP effect (exhaustive)
1. State flags + the paused timeline
const navToggler = document.querySelector(".nav-toggler");
const navBgs = document.querySelectorAll(".nav-bg"); // NodeList of the 4 layers
let isMenuOpen = false;
let isAnimating = false;
const tl = gsap.timeline({
paused: true,
onComplete: () => { isAnimating = false; },
onReverseComplete: () => {
gsap.set(linkBlocks.join(", "), { y: "100%" }); // re-hide every line after close
isAnimating = false;
},
});
The timeline is built once, paused. isAnimating is a re-entrancy guard set true on every click and cleared by onComplete (open) / onReverseComplete (close). On reverse-complete the link lines are force-reset back to y: "100%" because the link reveal is a separate set of tweens (not part of tl) and would otherwise stay revealed.
2. Timeline contents (exact order + timing)
Tween A — background curtain stack (appended at time 0):
tl.to(navBgs, {
scaleY: 1,
duration: 0.75,
stagger: 0.1,
ease: "power3.inOut",
});
All four .nav-bg layers animate scaleY: 0 → 1 from transform-origin: top, so each drops down from the top edge like a curtain. stagger: 0.1 fires them 0.1s apart (layer 1 at 0, layer 2 at 0.1, layer 3 at 0.2, layer 4 at 0.3), each over 0.75s on power3.inOut. Because the four colors are stacked with the light mint (#57cea5) first in DOM and the emerald/dark greens after, the staggered drop reads as overlapping colored curtains settling into place. This tween's total span = 0.75 + 0.1×3 = 1.05s.
Tween B — panel clip-path wipe (position "-=0.6", i.e. 0.6s before the end of tween A → starts at ≈ 1.05 − 0.6 = 0.45s):
tl.to(".nav-items", {
clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
duration: 0.75,
ease: "power3.inOut",
}, "-=0.6");
Animates the dark panel's clip-path from the degenerate top-pinned rectangle to the full rectangle, so the panel wipes open downward from the top edge over 0.75s on power3.inOut, overlapping the tail of the curtain drop. Timeline ends at ≈ 0.45 + 0.75 = 1.2s.
3. SplitText — split every menu anchor into masked lines
Created once at init (after the timeline object exists, since linkBlocks is referenced above):
const splitLinks = SplitText.create(".nav-items a", {
type: "lines",
mask: "lines",
linesClass: "line",
});
- Targets all anchors inside
.nav-items(socials, legal, primary, secondary). type: "lines"splits each link into line elements;mask: "lines"wraps each line in anoverflow: hiddenmask;linesClass: "line"tags each with.line, which the CSS pre-offsets totranslateY(100%)(hidden below its mask).
4. The link reveal (separate tweens, NOT on the timeline)
Links are grouped into three selector blocks:
const linkBlocks = [
".nav-socials .line, .nav-legal .line", // block 1: both small-link groups in col 1
".nav-primary-links .line", // block 2: the big display links
".nav-secondary-links .line", // block 3: the medium links
];
function animateLinksIn() {
linkBlocks.forEach((selector) => {
gsap.fromTo(
selector,
{ y: "100%" },
{
y: "0%",
duration: 0.75,
stagger: 0.05,
ease: "power3.out",
delay: 0.85,
},
);
});
}
Each block runs its own fromTo sliding its .line elements y: 100% → 0% over 0.75s on power3.out, with a 0.05s internal stagger and a shared 0.85s delay — so all three blocks begin revealing at ~0.85s after open is triggered (while the panel is still wiping in), each group's lines cascading up 0.05s apart from behind their masks.
5. Toggle wiring
navToggler.addEventListener("click", () => {
if (isAnimating) return;
isAnimating = true;
navToggler.classList.toggle("open"); // CSS morphs the two bars into an X
if (!isMenuOpen) {
tl.play(); // curtains drop + panel wipes open
animateLinksIn(); // fire the (delayed) link reveal in parallel
} else {
tl.reverse(); // same timeline backwards: panel wipes up, curtains retract
}
isMenuOpen = !isMenuOpen;
});
- Open:
tl.play()runs the timeline forward;animateLinksIn()is called at the same moment so the link reveal (delayed 0.85s) overlaps the panel wipe. Toggler gains.open→ CSS morphs bars into an X. - Close:
tl.reverse()plays the identical timeline backwards — panelclip-pathcollapses back to the top, curtains retract toscaleY: 0— andonReverseCompletere-hides the link lines (y: "100%"). Toggler loses.open→ X un-morphs to a hamburger. - The
isAnimatingguard ignores clicks mid-animation; it clears ononComplete(open finished) oronReverseComplete(close finished).
Assets / images
- 1 hero image — role: *fullscreen background behind the whole menu*. A bright, high-key abstract render of flowing white marble: glossy sculpted waves and swirling ridges with soft grey/charcoal veining threading through them. Dominant colors are white and light grey with darker grey vein accents — a light, airy surface (note: much brighter than the dark UI it sits under). Displayed
background-size: coverat50% 50%. Landscape ~16:9 (e.g. 1456×816 or larger). - 1 logo mark — role: *nav logo, top-left corner*. A near-white / off-white geometric outline mark (e.g. a rounded loop / racetrack shape) on a transparent background, square ~1:1 (512×512 PNG). Rendered at
40×40px. It reads on the dark hero but would be near-invisible on a light background.
Behavior notes
- Responsive (
max-width: 1000px):.nav-contentand.nav-itemsgetheight: 100svh;..nav-itemsbecomesflex-direction: column; justify-content: center; padding: 0 2rem;. The.nav-legaland.nav-secondary-linksgroups are hidden (display: none), and both.nav-items-cols switch toflex: none;. - Entirely click-driven — no scroll, no ScrollTrigger, no autoplay, no loops.
- The overlay
.nav-contentispointer-events: none, so the closed menu never blocks the hero. - Single source of truth for open/close is the one paused, reversible timeline; the link reveal is fired alongside it and reset on reverse-complete.