All components

Minimal Push Overlay Menu

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen overlay menu triggered by clicking the hamburger toggle: a GSAP timeline with a custom 'hop' ease pushes the page content down 100svh while the dark overlay expands via an animated clip-path polygon, its inner content slides up from -50% to 0, the media image fades in, and SplitText line-masked links stagger in with a negative stagger. Clicking again reverses the timeline.

How it's built

Categorymenu
Techgsap, lenis
GSAP pluginsCustomEase, SplitText
Complexitypage
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

menu overlay clip-path splittext stagger push lenis custom-ease navigation

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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 from https://fonts.cdnfonts.com/css/pp-neue-montreal and Inter from Google Fonts. Everything is weight 500.
  • h1: 7.5rem, letter-spacing: -0.2rem, line-height: 1.
  • p: 0.95rem. Base a: 1.5rem, color: var(--fg), no underline.
  • .menu-link a: 3.5rem, line-height: 1.2. .menu-tag a and .menu-footer p: color var(--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 initial translateY(0svh) matters because GSAP pushes it to 100svh.
  • 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). Its p: 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 2 translateY(3px) (two parallel lines).
  • .menu-hamburger-icon.active state: span 1 translateY(0) rotate(45deg) scaleX(1.05), span 2 translateY(0) rotate(-45deg) scaleX(1.05) — the lines merge into an X. JS only toggles the active class; CSS animates it.

Overlay

  • .menu-overlay and .menu-overlay-content: both position: fixed; top:0; left:0; width:100vw; height:100svh; color: var(--fg); overflow: hidden; z-index: 1;.
  • .menu-overlay: background: var(--menu-bg); with initial clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%) (a zero-height strip pinned to the top edge — fully hidden) and will-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 as yPercent: -50.
  • .menu-media-wrapper: flex: 2; opacity: 0; will-change: opacity; and its img { 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-footer the first col gets flex: 3, the second flex: 2.
  • .line (the class SplitText assigns to each line): position: relative; will-change: transform;.

GSAP effect (exhaustive)

Setup

  1. gsap.registerPlugin(CustomEase, SplitText); then CustomEase.create("hop", ".87,0,.13,1") — this bezier is the signature ease used by every structural tween.
  2. Lenis smooth scroll: const lenis = new Lenis() driven by a requestAnimationFrame loop calling lenis.raf(time).
  3. SplitText prep — for every .menu-col container (there are 4: links col, tags col, and the 2 footer cols), select all its a and p elements and split each element with SplitText.create(element, { type: "lines", mask: "lines", linesClass: "line" }) (the mask: "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 guardsisMenuOpen 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):

  1. .menu-toggle-label py: "-110%", duration: 1, ease: "hop" — the word "Menu" slides up out of its overflow-hidden wrapper and disappears.
  2. .containery: "100svh", duration: 1, ease: "hop" — the whole page is pushed down one full viewport (the "push-over").
  3. .menu-overlayclipPath: "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.
  4. .menu-overlay-contentyPercent: 0, duration: 1, ease: "hop" — the content rides up from its initial translateY(-50%) to its natural position (a parallax counter-movement against the wipe).
  5. .menu-media-wrapperopacity: 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.075 is negative, so within each group the last line starts first and the reveal runs bottom-up, 0.075s apart. The position parameter -0.15 means "insert 0.15s before the current end of the timeline" — and since each added tween extends the timeline's end (its span is 2 + 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.15 so 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":

  1. .containery: "0svh" — the page slides back up.
  2. .menu-overlayclipPath: "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).
  3. .menu-overlay-contentyPercent: -50 — the content slides back up behind the wipe.
  4. .menu-toggle-label py: "0%" — the word "Menu" slides back down into view.
  5. All .menu-col containers → 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:

  1. 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.
  2. 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.
  3. 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; isAnimating locks 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: -1 so the fixed nav/overlay always sit above them; the nav shell is pointer-events: none with pointer-events: all re-enabled only on the menu bar and the overlay content.
  • Responsive at max-width: 1000px: h1 drops to 3rem (letter-spacing -0.05rem, full width), the media wrapper is hidden (display: none), .menu-content-main and .menu-footer go full width, .menu-content-main becomes a column (align-items: flex-start; gap: 5rem), .menu-link a drops to 3rem and .menu-tag a to 1.25rem.
  • Use svh units throughout (heights and the container push) for correct mobile viewport behavior.