Minimal Push Overlay Menu
Goal
Build a dark, minimal one-page site with a fixed menu bar whose star effect is a fullscreen "push-over" overlay menu: clicking the circular hamburger toggle runs a single GSAP timeline (custom "hop" ease) that simultaneously pushes the entire page content down by 100svh, wipes the dark overlay open top-to-bottom via an animated clip-path polygon, slides the overlay's inner content up from -50% to 0, fades in a side media image, and reveals every menu link/tag/footer line with SplitText line-masked reveals in reverse stagger. Clicking again plays the mirrored closing timeline and the page slides back up.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins CustomEase and SplitText, and lenis for smooth scrolling. All logic in one script.js (<script type="module">), one styles.css, one index.html. Register plugins with gsap.registerPlugin(CustomEase, SplitText) and run everything inside a DOMContentLoaded listener.
Layout / HTML
<nav>
<div class="menu-bar">
<div class="menu-logo"><a href="#"><img src="<logo>" alt="" /></a></div>
<div class="menu-toggle-btn">
<div class="menu-toggle-label"><p>Menu</p></div>
<div class="menu-hamburger-icon"><span></span><span></span></div>
</div>
</div>
<div class="menu-overlay">
<div class="menu-overlay-content">
<div class="menu-media-wrapper"><img src="<menu image>" alt="" /></div>
<div class="menu-content-wrapper">
<div class="menu-content-main">
<div class="menu-col">
<!-- 5 large links, each: <div class="menu-link"><a href="#">…</a></div> -->
<!-- Index · Portfolio · Studio · Journal · Connect -->
</div>
<div class="menu-col">
<!-- 3 small tags, each: <div class="menu-tag"><a href="#">…</a></div> -->
<!-- Web Animations · Interactive Media · Motion Craft -->
</div>
</div>
<div class="menu-footer">
<div class="menu-col"><p>Toronto, Canada</p></div>
<div class="menu-col"><p>+1 437 555 0199</p><p>hello@nullspace.studio</p></div>
</div>
</div>
</div>
</div>
</nav>
<div class="container">
<section class="hero"><h1>Modern design system made that looks timeless</h1></section>
<section class="banner"><img src="<banner image>" alt="" /></section>
<section class="outro"><h1>Let’s build something quietly iconic</h1></section>
</div>
Styling
Palette (CSS variables)
--bg: #171717(page background),--fg: #fff,--menu-bg: #0f0f0f(overlay background),--menu-fg-secondary: #5f5f5f(muted grey for tags/footer/menu-bar),--hamburger-icon-border: rgba(255, 255, 255, 0.1).
Typography
body { font-family: "PP Neue Montreal", "Inter", sans-serif; }— import PP Neue Montreal fromhttps://fonts.cdnfonts.com/css/pp-neue-montrealand Inter from Google Fonts. Everything is weight 500.h1:7.5rem,letter-spacing: -0.2rem,line-height: 1.p:0.95rem. Basea:1.5rem,color: var(--fg), no underline..menu-link a:3.5rem,line-height: 1.2..menu-tag aand.menu-footer p: colorvar(--menu-fg-secondary).- Global reset (
* { margin:0; padding:0; box-sizing:border-box; }),img { width:100%; height:100%; object-fit: cover; }.
Page
.container:position: relative; transform: translateY(0svh); background: var(--bg); color: var(--fg);— the initialtranslateY(0svh)matters because GSAP pushes it to100svh.section:position: relative; width: 100vw; height: 100svh; padding: 2rem; display:flex; justify-content:center; align-items:center; overflow:hidden; z-index: -1;(the negative z-index keeps sections under the fixed nav).section h1 { width: 75%; },section img { opacity: 0.5; }.
Fixed nav shell
nav:position: fixed; inset-top-left; width:100vw; height:100svh; pointer-events: none; overflow: hidden; z-index: 2;— the nav itself is click-through; only its children re-enable pointer events..menu-bar:position: fixed; top:0; left:0; width:100vw; padding:2rem; display:flex; justify-content: space-between; align-items:center; pointer-events: all; color: var(--menu-fg-secondary); z-index: 2;.menu-logo:2rem × 2rem..menu-toggle-btn: flex row,gap: 1rem,cursor: pointer..menu-toggle-label:overflow: hidden(this clips the label when GSAP slides the<p>up). Itsp:position: relative; transform: translateY(0%); will-change: transform;.
Hamburger icon (pure CSS morph)
.menu-hamburger-icon:position: relative; width:3rem; height:3rem;circular (border-radius: 100%),border: 1px solid var(--hamburger-icon-border), flex column centered.- Each
span:position:absolute; width:15px; height:1.25px; background: var(--fg); transform-origin: center; will-change: transform;and — key —transition: all 0.75s cubic-bezier(0.87, 0, 0.13, 1);(the same curve as the GSAP "hop" ease). - Resting state: span 1
translateY(-3px), span 2translateY(3px)(two parallel lines). .menu-hamburger-icon.activestate: span 1translateY(0) rotate(45deg) scaleX(1.05), span 2translateY(0) rotate(-45deg) scaleX(1.05)— the lines merge into an X. JS only toggles theactiveclass; CSS animates it.
Overlay
.menu-overlayand.menu-overlay-content: bothposition: fixed; top:0; left:0; width:100vw; height:100svh; color: var(--fg); overflow: hidden; z-index: 1;..menu-overlay:background: var(--menu-bg);with initialclip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)(a zero-height strip pinned to the top edge — fully hidden) andwill-change: clip-path..menu-overlay-content:display: flex; transform: translateY(-50%); will-change: transform; pointer-events: all;— it starts shifted up half a screen; GSAP parses that asyPercent: -50..menu-media-wrapper:flex: 2; opacity: 0; will-change: opacity;and itsimg { opacity: 0.25; }(a dim, moody panel)..menu-content-wrapper:flex: 3; position: relative; display: flex;..menu-content-main:position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);..menu-footer:margin: 0 auto;. Both:width: 75%; padding: 2rem; display: flex; align-items: flex-end; gap: 2rem;..menu-col: flex column,gap: 0.5rem; inside.menu-content-main/.menu-footerthe first col getsflex: 3, the secondflex: 2..line(the class SplitText assigns to each line):position: relative; will-change: transform;.
GSAP effect (exhaustive)
Setup
gsap.registerPlugin(CustomEase, SplitText); thenCustomEase.create("hop", ".87,0,.13,1")— this bezier is the signature ease used by every structural tween.- Lenis smooth scroll:
const lenis = new Lenis()driven by arequestAnimationFrameloop callinglenis.raf(time). - SplitText prep — for every
.menu-colcontainer (there are 4: links col, tags col, and the 2 footer cols), select all itsaandpelements and split each element withSplitText.create(element, { type: "lines", mask: "lines", linesClass: "line" })(themask: "lines"option wraps every line in its own overflow-clipping mask wrapper). Keep the splits grouped per container in an array of arrays (splitTextByContainer). Immediately hide every line:gsap.set(split.lines, { y: "-110%" })— each line waits above its mask.
State guards — isMenuOpen and isAnimating booleans. The toggle click handler returns early while isAnimating is true, so the animation can never be interrupted mid-flight.
OPEN timeline (click while closed): set isAnimating = true, call lenis.stop() (page scroll is frozen while the menu is open), then build one gsap.timeline() where the first five tweens all start together at position "<" (time 0):
.menu-toggle-label p→y: "-110%",duration: 1,ease: "hop"— the word "Menu" slides up out of its overflow-hidden wrapper and disappears..container→y: "100svh",duration: 1,ease: "hop"— the whole page is pushed down one full viewport (the "push-over")..menu-overlay→clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",duration: 1,ease: "hop"— the overlay's bottom edge sweeps from the top of the screen down to the bottom, revealing the dark menu in a top-anchored wipe that moves in lockstep with the page being pushed down..menu-overlay-content→yPercent: 0,duration: 1,ease: "hop"— the content rides up from its initialtranslateY(-50%)to its natural position (a parallax counter-movement against the wipe)..menu-media-wrapper→opacity: 1,duration: 0.75,ease: "power2.out",delay: 0.5, positioned at"<"— the side image fades in during the second half of the wipe (effective window ≈ 0.5s→1.25s).
Line reveals — after those five, loop over splitTextByContainer; for each container, flatten its splits into one array of line elements (containerSplits.flatMap(split => split.lines)) and add:
tl.to(copyLines, { y: "0%", duration: 2, ease: "hop", stagger: -0.075 }, -0.15)- Details that matter:
stagger: -0.075is negative, so within each group the last line starts first and the reveal runs bottom-up, 0.075s apart. The position parameter-0.15means "insert 0.15s before the current end of the timeline" — and since each added tween extends the timeline's end (its span is2 + 0.075 × (lines − 1)seconds), the four container groups chain into a cascade, each group beginning 0.15s before the previous group's tween finishes: group 1 (5 link lines) starts at ≈1.1s, group 2 (3 tag lines) at ≈3.25s, group 3 (1 footer line) at ≈5.25s, group 4 (2 footer lines) at ≈7.1s. Do NOT insert them all at the same time — add them sequentially in a loop with position-0.15so this cascade emerges naturally.
Also on open: hamburgerIcon.classList.add("active") (CSS morphs the icon to an X over 0.75s), and finish with tl.call(() => { isAnimating = false; }). Set isMenuOpen = true.
CLOSE timeline (click while open): set isAnimating = true, remove the active class from the hamburger, then one timeline with five tweens all starting together (first at default position, the rest at "<"), all duration: 1, ease: "hop":
.container→y: "0svh"— the page slides back up..menu-overlay→clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)"— the overlay collapses back to a zero-height strip at the top (bottom edge sweeps upward)..menu-overlay-content→yPercent: -50— the content slides back up behind the wipe..menu-toggle-label p→y: "0%"— the word "Menu" slides back down into view.- All
.menu-colcontainers →opacity: 0.25— the menu copy dims as it exits.
End the close timeline with a tl.call() that resets everything instantly for the next open: gsap.set all split lines back to y: "-110%", gsap.set the .menu-col containers back to opacity: 1, gsap.set(.menu-media-wrapper, { opacity: 0 }), then isAnimating = false and lenis.start(). Set isMenuOpen = false.
Assets / images
3 images, described by role:
- Logo — a small square (1:1) abstract studio logo mark on transparent background (PNG), rendered at 2rem in the top-left of the menu bar.
- Menu overlay media — one moody, dark editorial photograph (roughly portrait/full-height crop; it fills the left 2/5 of the overlay at full viewport height,
object-fit: cover) shown at 25% opacity as an ambient panel inside the open menu. - Page banner — one large atmospheric photograph filling the middle full-screen section (
100vw × 100svh,object-fit: cover) at 50% opacity behind the dark background.
No real brand names — use the neutral fictional studio contact shown in the HTML above.
Behavior notes
- The open/close animations are click-triggered only;
isAnimatinglocks out clicks mid-animation. - Lenis smooth scroll runs on the page and is stopped while the menu is open, restarted on close.
- The page sections use
z-index: -1so the fixed nav/overlay always sit above them; the nav shell ispointer-events: nonewithpointer-events: allre-enabled only on the menu bar and the overlay content. - Responsive at
max-width: 1000px:h1drops to3rem(letter-spacing-0.05rem, full width), the media wrapper is hidden (display: none),.menu-content-mainand.menu-footergo full width,.menu-content-mainbecomes a column (align-items: flex-start; gap: 5rem),.menu-link adrops to3remand.menu-tag ato1.25rem. - Use
svhunits throughout (heights and the container push) for correct mobile viewport behavior.