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 iswidth: 80%.- All
a, p:position: relative, no underline, color#fff,font-size: 1rem,font-weight: 300,user-select: none. Logo link isfont-weight: 600. nav:position: fixed,width: 100vw,padding: 2.5em, flexspace-between+ centered,z-index: 2..menu-toggle:position: relative,width: 3rem,height: 1.5rem,cursor: pointer. Bothpchildren areposition: absolutewithtransform-origin: top leftandwill-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-itemsand.menu-footer:width: 100%,padding: 2.5em,display: flex,gap: 2.5em..col-lg { flex: 3 },.col-sm { flex: 2 }..menu-footeris absolutely positioned atbottom: 0; its.col-smis flexspace-between..menu-items .col-lg: flex, centered — it holds.menu-preview-img:position: relative,width: 45%,height: 100%,overflow: hidden. Itsimgs areposition: 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-linksand.menu-socialsare column flex withgap: 0.5em..linkand.socialwrappers:padding-bottom: 6pxandclip-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 aisfont-size: 3.5rem,letter-spacing: -0.02rem..social ais#8f8f8fand turns#fffon hover.- Underline hover on
.link a,.social a,.menu-footer avia::after: absolute,top: 102.5%,left: 0, full width,height: 2px, white,transform: scaleX(0)withtransform-origin: right,transition: transform 0.3s cubic-bezier(0.6, 0, 0.4, 1); on hoverscaleX(1)withtransform-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 withalign-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
- Page container pushed away —
gsap.to(".container", { rotation: 10, x: 300, y: 450, scale: 1.5, duration: 1.25, ease: "power4.inOut" }). Because.containerhastransform-origin: right top, the page swings down-right and zooms. - Toggle label swap (shared helper, see below) — "Menu" out, "Close" in.
- Menu content settles —
gsap.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. Withtransform-origin: left bottomit reads as the content rotating/zooming into place. - Links + socials rise —
gsap.to([".link a", ".social a"], { y: "0%", opacity: 1, delay: 0.75, duration: 1, stagger: 0.1, ease: "power3.out" }). They come up fromy: 120%behind the wrappers' clip-path masks. Single flat stagger across all 8 anchors (4 links then 4 socials), 0.1s apart. - Overlay clip-path opens —
gsap.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: setisOpen = true,isAnimating = false.
closeMenu() — mirror of open, all fired at once
gsap.to(".container", { rotation: 0, x: 0, y: 0, scale: 1, duration: 1.25, ease: "power4.inOut" }).- Toggle label swap — "Close" out, "Menu" in.
gsap.to(".menu-content", { rotation: -15, x: -100, y: -100, scale: 1.5, opacity: 0.25, duration: 1.25, ease: "power4.inOut" }).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, thengsap.set([".link a", ".social a"], { y: "120%" })(instantly re-hide the links for the next open — onlyyis reset, not opacity), and reset the preview container back to a single defaultimg-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-openwhen opening,#menu-closewhen 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-closeits 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-imgalready has that src (compare withsrc.endsWith(imgSrc)), so re-hovering the same link does nothing. - Create a new
<img>with that src, give it inline starting stylesopacity: 0andtransform: 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.jpg…img-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-1doubles as the default preview shown when the menu opens; each of the 4 links maps to one image viadata-img.
Behavior notes
- Under
max-width: 900px: hero h1 becomes4rem, full width,letter-spacing: 0; the preview column (.menu-items .col-lg) is hidden entirely; the::afterhover 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
displaytoggling. - The
isAnimatinglock prevents re-triggering the toggle and hover swaps mid-transition; all open/close tweens share the same1.25s/power4.inOutsignature so everything moves as one gesture. - No scroll behavior, no ScrollTrigger, no smooth-scroll library — this is a click/hover-driven, page-level component.