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.
Images
This component ships with 5 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/exoape-menu-javascript/hero.jpg
https://motionprompts.dev/c/exoape-menu-javascript/img-1.jpg
https://motionprompts.dev/c/exoape-menu-javascript/img-2.jpg
https://motionprompts.dev/c/exoape-menu-javascript/img-3.jpg
https://motionprompts.dev/c/exoape-menu-javascript/img-4.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,--plaster,--clay,--muted,--accent,--hairline,--display,--text. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 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. - 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 runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.
Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Setup that runs twice with teardown that runs never leaves you two of everything: two triggers on the same element disagreeing about the same scrub, two smooth scrollers pulling on the same wheel event. The visible symptom is jitter or doubled speed, and it will not reproduce in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.
*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no error, no menu, nothing to debug. Delete the listener and put its body directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — Every document.querySelector in the code above (.container, .menu-toggle, .menu-overlay, .menu-content, .menu-preview-img, .link a, and the pair p#menu-open / p#menu-close re-queried inside animateMenuToggle on every call) assumes this component owns the document. Give the component a root ref, render it on the outermost wrapper, and scope every lookup to it (root.querySelector(...), root.querySelectorAll(".link a")). Unscoped selectors are not a style problem here: during the StrictMode remount two copies of the menu markup exist for an instant, and an unscoped selector will bind to the copy that is on its way out — so a click on the toggle you see on screen could animate the container GSAP already reverted.
*(3) Cleanup* — Wrap the whole effect in a gsap.context scoped to the root ref, and revert that context on cleanup. But gsap.context only tracks tweens it sees created during the synchronous call it makes to your factory. openMenu, closeMenu, and the per-link hover handler here all fire later, from a click or a mouseover — outside that synchronous pass — so if you leave them as plain functions, ctx.revert() will not know about the tweens they start and an in-flight open/close animation survives an unmount untouched. Register each one with the factory's self parameter instead of calling it directly, and drive every listener through the name the context now exposes:
useEffect(() => {
const root = rootRef.current;
const ctx = gsap.context((self) => {
self.add("openMenu", () => { /* the five parallel tweens from openMenu() above */ });
self.add("closeMenu", () => { /* the mirrored tweens from closeMenu() above */ });
self.add("previewHover", (link) => {
/* build the new <img>, append it, then gsap.to(newImg, { ... }) */
});
}, root);
const toggle = root.querySelector(".menu-toggle");
const onToggleClick = () => (isOpenRef.current ? ctx.closeMenu() : ctx.openMenu());
toggle.addEventListener("click", onToggleClick);
const links = root.querySelectorAll(".link a");
const onHover = (e) => ctx.previewHover(e.currentTarget);
links.forEach((link) => link.addEventListener("mouseover", onHover));
return () => {
toggle.removeEventListener("click", onToggleClick);
links.forEach((link) => link.removeEventListener("mouseover", onHover));
ctx.revert();
};
}, []);
The isOpen / isAnimating booleans can stay plain variables closed over by this same effect (a ref if anything outside the effect needs to read them) — they gate behavior, not what renders, so putting them in useState would only trigger re-renders no part of this component needs. What must not stay a plain closure is the click and mouseover wiring itself: addEventListener is not a GSAP resource, so ctx.revert() never touches it. Without the explicit removeEventListener pair above, a StrictMode remount leaves the first mount's toggle listener attached to a node that has been reverted, and a click after that fires ctx.openMenu against tweens whose targets GSAP no longer owns.
*(4) The preview image stack is raw DOM, not JSX, and it lives inside a node React also renders into* — The layout's .menu-preview-img starts with one <img> written in JSX, matching the markup above. previewHover and resetPreviewImage then manage that same container with appendChild, removeChild, and innerHTML = "", stacking and trimming plain <img> elements that React never put there and does not know about. This works as long as React never re-renders that subtree after mount. If any state change in a parent component causes this component to re-render with new children for .menu-preview-img, React's reconciler will diff against the single <img> it remembers rendering and can remove or reorder the images the effect appended, out of step with whatever gsap.to is mid-tween on. Keep that container's contents entirely on one side of the boundary: render it once as an empty ref target with no children in JSX, and let previewHover / resetPreviewImage — including the very first default image — populate it imperatively from inside the effect, the same way closeMenu's onComplete already does today.