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: "Hanken Grotesk", "Inter", sans-serif; } — import both from Google Fonts: @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@300;400;500;600;700;800&display=swap"); and the equivalent for Inter. 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.

Images

This component ships with 3 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/push-over-overlay-menu/hero.jpg
https://motionprompts.dev/c/push-over-overlay-menu/logo.png
https://motionprompts.dev/c/push-over-overlay-menu/menu-media.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--bg, --fg, --menu-bg, --menu-fg-secondary, --hairline, --accent, --hamburger-icon-border. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the lenis.on("scroll", ScrollTrigger.update) wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: one script that waits for DOMContentLoaded, then wires a single click listener to .menu-toggle-btn that builds a brand-new gsap.timeline() on every open and every close, and never expects a second copy of itself to exist. React withdraws that guarantee.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. This component is unusually exposed to that, because isMenuOpen, isAnimating, and the click listener that reads them all live inside one closure: leave the vanilla addEventListener in place with no matching removeEventListener, and the remount leaves two listeners on the same button, one of them closing over state from a copy of the component that no longer exists. A single click then fires both handlers — two open timelines racing to push the same .container and wipe the same .menu-overlay's clip-path, and two SplitText passes splitting the already-split .menu-col lines into spans nested one level deeper, so from that click on the line-mask reveal targets the wrong nodes. None of this shows up in a production build, where the double-invoke never happens — only in development, and only starting on the second click.

*(1) The entry point* — The script wraps its entire body — the CustomEase registration, the Lenis instance and its own requestAnimationFrame loop, the SplitText prep over every .menu-col, and the click listener on .menu-toggle-btn — inside document.addEventListener("DOMContentLoaded", …). By the time a React component mounts, that event has already fired, so the listener is never called: no split lines, no working toggle, no error either. Delete the listener and move its body into a useEffect with an empty dependency array. gsap.registerPlugin(CustomEase, SplitText) and the CustomEase.create("hop", …) call right after it touch no DOM and gain nothing from re-running on every mount — leave both at module scope, next to the imports.

*(2) Element lookups* — The markup is two siblings under the page root: nav (holding .menu-bar and .menu-overlay) and .container (the three sections the toggle pushes down). Wrap both in one root element with a ref and scope every lookup to it: .container, .menu-toggle-btn, .menu-overlay, .menu-overlay-content, .menu-media-wrapper, .menu-toggle-label p, .menu-hamburger-icon, and .menu-col. That last one is queried twice in the script — once as textContainers to build the splits, again as copyContainers to reset opacity on close — resolve it once through the context's scoped selector and reuse the result for both instead of asking the DOM for the same four nodes a second time.

*(3) Cleanup* — Wrap the SplitText prep and the Lenis setup in a gsap.context scoped to the root ref. Neither openMenu nor closeMenu exists yet when that factory runs — each is a fresh gsap.timeline() built later, inside handleClick, on whichever click triggers it — so register both as named context methods:

useEffect(() => {
  let rafId;
  let lenis;
  let splitTextByContainer = [];
  let isMenuOpen = false;
  let isAnimating = false;

  const ctx = gsap.context((self) => {
    const q = self.selector;
    const menuCols = q(".menu-col");

    menuCols.forEach((container) => {
      const textElements = container.querySelectorAll("a, p");
      const containerSplits = [];
      textElements.forEach((element) => {
        const split = SplitText.create(element, { type: "lines", mask: "lines", linesClass: "line" });
        containerSplits.push(split);
        gsap.set(split.lines, { y: "-110%" });
      });
      splitTextByContainer.push(containerSplits);
    });

    lenis = new Lenis();
    function raf(time) {
      lenis.raf(time);
      rafId = requestAnimationFrame(raf);
    }
    rafId = requestAnimationFrame(raf);

    self.add("openMenu", () => {
      /* the OPEN timeline exactly as documented above, built against q(...) targets and the
         flattened splitTextByContainer lines; its tl.call() at the end flips isAnimating false */
    });

    self.add("closeMenu", () => {
      /* the CLOSE timeline; its tl.call() resets every split line and menuCols back to hidden,
         restarts lenis, and flips isAnimating false */
    });
  }, rootRef);

  const toggleBtn = rootRef.current.querySelector(".menu-toggle-btn");
  const handleClick = () => {
    if (isAnimating) return;
    isAnimating = true;
    if (!isMenuOpen) {
      lenis.stop();
      ctx.openMenu();
    } else {
      ctx.closeMenu();
    }
    isMenuOpen = !isMenuOpen;
  };
  toggleBtn.addEventListener("click", handleClick);

  return () => {
    toggleBtn.removeEventListener("click", handleClick);
    cancelAnimationFrame(rafId);
    lenis.destroy();
    ctx.revert();
    splitTextByContainer.flat().forEach((split) => split.revert());
  };
}, []);

The gsap.set calls inside the menuCols.forEach loop run during the context's synchronous pass, so ctx.revert() already knows to undo that hidden line-mask offset on teardown. openMenu and closeMenu do not get that for free: both are built from scratch, later, from inside handleClick, so without self.add neither the push-over tween on .container, nor the clip-path wipe on .menu-overlay, nor the line reveal, is ever visible to ctx.revert() — a StrictMode unmount that lands mid-open leaves that timeline running against a .container React has already discarded. Call the two methods back as ctx.openMenu() / ctx.closeMenu(), never openMenu()/closeMenu() directly, and never write ctx.add( inside the factory itself — the parameter the factory receives is self; ctx is still in its temporal dead zone there. isMenuOpen and isAnimating stay plain closured lets rather than useRef: nothing in the JSX reads them (the hamburger's morph to an X is the .active class reacting in CSS, not a React re-render), and because handleClick is removed on every unmount, each new effect run starts with its own clean pair — there is no stale-closure case here the way there is when a leaked listener lets two generations of state coexist.

Lenis's raf loop lives inside the same factory, driven by your own requestAnimationFrame, not Lenis's own scheduling. Keep the handle and cancel it in the cleanup before calling destroy(), or a frame already queued calls lenis.raf() against an instance that no longer exists. Nothing about this page implies a shared scroller — the whole document is this component — so owning the Lenis instance here, rather than lifting it to an app shell, is the right default; revisit that only if this menu ends up mounted as one section inside a larger app that already runs its own Lenis.

Splitting rewrites every a/p inside .menu-col into per-line spans wrapped in a masking element; because splitTextByContainer holds one SplitText instance per text element (the two footer columns each hold two <p> tags, not one), flatten it and revert every instance in the cleanup — after ctx.revert(), not before, so any openMenu/closeMenu tween still in flight against .line nodes is killed before the nodes it targets are removed. One element here is genuinely sensitive to which face is active when the split runs: .menu-tag a holds two-word labels ("Web Animations", "Interactive Media") narrow enough to wrap depending on the metrics of whichever font is current. If Hanken Grotesk swaps in after the split already ran against the Inter fallback and a tag's line count changes, the newly-created line span never gets the initial hidden offset — that gsap.set already fired — so it renders in place, out of step with the rest of the cascade. If this component might mount before the custom face is active, gate the .menu-col split behind document.fonts.ready, with a cancelled flag the cleanup sets so a resolution that lands after unmount doesn't split a node that is already gone.