All components

ExoApe-Inspired Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen overlay navigation menu inspired by ExoApe. Clicking the Menu toggle drives a synchronized GSAP timeline: the page container rotates/scales/translates away (power4.inOut), the overlay's clip-path polygon skews open, the menu content settles from a rotated-scaled state, and the links stagger up from below (power3.out). Hovering a link swaps a stacked preview image in with a scale/rotate fade (power2.out).

How it's built

Categorymenu
Techgsap
Complexitypage
Performance costlight
Mobile-safedesktop-first

menu overlay fullscreen clip-path stagger hover-preview gsap exoape elegant

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

Fullscreen Overlay Menu with Skewed Clip-Path Reveal & Hover Image Preview

Goal

Build a fullscreen overlay navigation menu (ExoApe-style). Clicking a "Menu" toggle fires a set of synchronized GSAP tweens: the page content rotates/scales/translates away to the bottom-right, a dark overlay opens via an animated clip-path polygon with a skewed bottom edge, the menu content "settles" from a rotated/zoomed state into place, and the nav links rise into view with a stagger. Hovering each link cross-fades a stacked preview image with a scale + rotate entrance.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm). No GSAP plugins are needed — only core tweens (gsap.to / gsap.set) animating transforms and clipPath.

import gsap from "gsap";

Wrap all JS in DOMContentLoaded.

Layout / HTML

<nav>
  <div class="logo"><a href="#">Void Construct</a></div>
  <div class="menu-toggle">
    <p id="menu-open">Menu</p>
    <p id="menu-close">Close</p>
  </div>
</nav>

<div class="menu-overlay">
  <div class="menu-content">
    <div class="menu-items">
      <div class="col-lg">
        <div class="menu-preview-img"><img src="./img-1.jpg" alt="" /></div>
      </div>
      <div class="col-sm">
        <div class="menu-links">
          <div class="link"><a href="#" data-img="./img-1.jpg">Visions</a></div>
          <div class="link"><a href="#" data-img="./img-2.jpg">Core</a></div>
          <div class="link"><a href="#" data-img="./img-3.jpg">Signals</a></div>
          <div class="link"><a href="#" data-img="./img-4.jpg">Connect</a></div>
        </div>
        <div class="menu-socials">
          <div class="social"><a href="#">Behance</a></div>
          <div class="social"><a href="#">Dribbble</a></div>
          <div class="social"><a href="#">LinkedIn</a></div>
          <div class="social"><a href="#">Instagram</a></div>
        </div>
      </div>
    </div>
    <div class="menu-footer">
      <div class="col-lg"><a href="#">Run Sequence</a></div>
      <div class="col-sm"><a href="#">Origin</a><a href="#">Join Signal</a></div>
    </div>
  </div>
</div>

<div class="container">
  <section class="hero">
    <div class="hero-img"><img src="./hero.jpg" alt="" /></div>
    <h1>Digital architecture that rises from the void.</h1>
  </section>
</div>

Note the stacking: nav sits above everything (z-index: 2), .menu-overlay above the page (z-index: 1), and .container holds the actual page (a fullscreen hero).

Styling

  • Reset: * { margin: 0; padding: 0; box-sizing: border-box; }. body { overflow-x: hidden; }.
  • Font: "TWK Lausanne", "Inter", sans-serif — import Inter from Google Fonts as the working fallback.
  • img { width: 100%; height: 100%; object-fit: cover; }.
  • h1: color #fff, font-size: 7rem, font-weight: 400, letter-spacing: -0.2rem, line-height: 1. Inside .hero, the h1 is width: 80%.
  • All a, p: position: relative, no underline, color #fff, font-size: 1rem, font-weight: 300, user-select: none. Logo link is font-weight: 600.
  • nav: position: fixed, width: 100vw, padding: 2.5em, flex space-between + centered, z-index: 2.
  • .menu-toggle: position: relative, width: 3rem, height: 1.5rem, cursor: pointer. Both p children are position: absolute with transform-origin: top left and will-change: transform, opacity (they stack on top of each other).
  • .menu-overlay: position: fixed, width: 100vw, height: 100svh, background #0f0f0f, z-index: 1.
  • .menu-content: position: relative, 100% width/height, flex centered, transform-origin: left bottom, will-change: opacity, transform.
  • .menu-items and .menu-footer: width: 100%, padding: 2.5em, display: flex, gap: 2.5em. .col-lg { flex: 3 }, .col-sm { flex: 2 }. .menu-footer is absolutely positioned at bottom: 0; its .col-sm is flex space-between.
  • .menu-items .col-lg: flex, centered — it holds .menu-preview-img: position: relative, width: 45%, height: 100%, overflow: hidden. Its imgs are position: absolute (they stack for the cross-fade), will-change: transform, opacity.
  • .menu-items .col-sm: padding: 2.5em 0, column flex, gap: 2.5em. .menu-links and .menu-socials are column flex with gap: 0.5em.
  • .link and .social wrappers: padding-bottom: 6px and clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%) — this rectangle clip is the mask that hides the links when they are translated down.
  • .link a, .social a: display: inline-block, will-change: transform, transition: color 0.5s. .link a is font-size: 3.5rem, letter-spacing: -0.02rem. .social a is #8f8f8f and turns #fff on hover.
  • Underline hover on .link a, .social a, .menu-footer a via ::after: absolute, top: 102.5%, left: 0, full width, height: 2px, white, transform: scaleX(0) with transform-origin: right, transition: transform 0.3s cubic-bezier(0.6, 0, 0.4, 1); on hover scaleX(1) with transform-origin: left (draws left→right in, right→left out).
  • .container: position: relative, 100% width/height, transform-origin: right top, will-change: transform.
  • .hero: position: relative, 100vw × 100svh, padding: 2.5em, flex with align-items: flex-end, overflow: hidden. .hero-img: absolute, top/left 0, 100% × 100svh, z-index: -1.
Initial (closed) states — set these in CSS
.menu-toggle p#menu-close {
  opacity: 0;
  transform: translateX(-5px) translateY(10px) rotate(5deg);
}
.link a, .social a {
  transform: translateY(120%);
  opacity: 0.25;
}
.menu-content {
  transform: translateX(-100px) translateY(-100px) scale(1.5) rotate(-15deg);
  opacity: 0.25;
}
.menu-overlay {
  clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); /* collapsed to zero height */
}

GSAP effect (exhaustive)

State: two booleans, isOpen = false and isAnimating = false. Clicking .menu-toggle calls openMenu() if closed, closeMenu() if open. Both bail out if isAnimating is true (or if already in the target state).

openMenu() — five parallel tweens, all fired at once
  1. Page container pushed awaygsap.to(".container", { rotation: 10, x: 300, y: 450, scale: 1.5, duration: 1.25, ease: "power4.inOut" }). Because .container has transform-origin: right top, the page swings down-right and zooms.
  2. Toggle label swap (shared helper, see below) — "Menu" out, "Close" in.
  3. Menu content settlesgsap.to(".menu-content", { rotation: 0, x: 0, y: 0, scale: 1, opacity: 1, duration: 1.25, ease: "power4.inOut" }) — from its CSS initial state (-100, -100, scale 1.5, -15deg, opacity 0.25) to identity. With transform-origin: left bottom it reads as the content rotating/zooming into place.
  4. Links + socials risegsap.to([".link a", ".social a"], { y: "0%", opacity: 1, delay: 0.75, duration: 1, stagger: 0.1, ease: "power3.out" }). They come up from y: 120% behind the wrappers' clip-path masks. Single flat stagger across all 8 anchors (4 links then 4 socials), 0.1s apart.
  5. Overlay clip-path opensgsap.to(".menu-overlay", { clipPath: "polygon(0% 0%, 100% 0%, 100% 175%, 0% 100%)", duration: 1.25, ease: "power4.inOut" }). The bottom-right corner overshoots to 175%, so while opening the bottom edge is diagonal/skewed (the overlay wipes down with a slanted edge). onComplete: set isOpen = true, isAnimating = false.
closeMenu() — mirror of open, all fired at once
  1. gsap.to(".container", { rotation: 0, x: 0, y: 0, scale: 1, duration: 1.25, ease: "power4.inOut" }).
  2. Toggle label swap — "Close" out, "Menu" in.
  3. gsap.to(".menu-content", { rotation: -15, x: -100, y: -100, scale: 1.5, opacity: 0.25, duration: 1.25, ease: "power4.inOut" }).
  4. gsap.to(".menu-overlay", { clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)", duration: 1.25, ease: "power4.inOut" }) — collapses back to the zero-height polygon. onComplete: isOpen = false, isAnimating = false, then gsap.set([".link a", ".social a"], { y: "120%" }) (instantly re-hide the links for the next open — only y is reset, not opacity), and reset the preview container back to a single default img-1 (menuPreviewImg.innerHTML = "" then append a fresh <img src="./img-1.jpg">).

Note: the links are NOT animated out during close — the overlay clip-path and content transform hide them; they are snapped back down only after the close completes.

Toggle label swap helper — animateMenuToggle(isOpening)

Two tweens on the stacked p#menu-open / p#menu-close:

  • Outgoing label (#menu-open when opening, #menu-close when closing): gsap.to(el, { x: -5, y: isOpening ? -10 : 10, rotation: isOpening ? -5 : 5, opacity: 0, delay: 0.25, duration: 0.5, ease: "power2.out" }) — it drifts up-left with a small counter-rotation when opening, down-left with positive rotation when closing.
  • Incoming label: gsap.to(el, { x: 0, y: 0, rotation: 0, opacity: 1, delay: 0.5, duration: 0.5, ease: "power2.out" }) — returns to identity from wherever the previous cycle left it (the CSS initial state gives #menu-close its offscreen pose on first load).
Hover image preview (link → stacked image cross-fade)

For every .link a, on mouseover:

  • Ignore unless isOpen && !isAnimating.
  • Read the link's data-img. Bail if the LAST <img> currently inside .menu-preview-img already has that src (compare with src.endsWith(imgSrc)), so re-hovering the same link does nothing.
  • Create a new <img> with that src, give it inline starting styles opacity: 0 and transform: scale(1.25) rotate(10deg), and append it on top of the stack inside .menu-preview-img.
  • Cleanup: if the container now holds more than 3 images, remove the oldest ones so at most 3 remain (old images just sit underneath — no exit animation).
  • Animate the new image in: gsap.to(newImg, { opacity: 1, scale: 1, rotation: 0, duration: 0.75, ease: "power2.out" }).

Assets / images

5 photographs, all object-fit: cover so exact ratios are forgiving:

  • hero.jpg — 1 moody, dark architectural/editorial photo, full-bleed landscape (~16:9), used as the fullscreen hero background behind the big white headline.
  • img-1.jpgimg-4.jpg — 4 atmospheric editorial photos in the same dark visual family, displayed in a portrait-ish frame (the preview box is 45% of the left column's width and full height, roughly 3:4). img-1 doubles as the default preview shown when the menu opens; each of the 4 links maps to one image via data-img.

Behavior notes

  • Under max-width: 900px: hero h1 becomes 4rem, full width, letter-spacing: 0; the preview column (.menu-items .col-lg) is hidden entirely; the ::after hover underlines are disabled.
  • The overlay stays permanently in the DOM covering the viewport — it is only ever hidden by its collapsed clip-path, so no display toggling.
  • The isAnimating lock prevents re-triggering the toggle and hover swaps mid-transition; all open/close tweens share the same 1.25s / power4.inOut signature so everything moves as one gesture.
  • No scroll behavior, no ScrollTrigger, no smooth-scroll library — this is a click/hover-driven, page-level component.