Full-Screen Overlay Menu with Clip-Path Wipe & Lerped Highlighter
Goal
Build a fixed top navbar whose Menu toggle opens a full-screen dark overlay that wipes open from the bottom edge upward via an animated clip-path polygon. As it opens, two columns of meta text fade/slide in, a small centered image scales up, and a row of five oversized (Anton, 10rem) menu links stagger up into view from a mask. On desktop, each link does a per-character vertical swap on hover (SplitText), a lime highlighter bar slides + resizes to track the hovered link using a lerp loop, and the entire link strip pans horizontally with the mouse X position (also lerped). It is a click-to-open/close menu, not scroll-driven.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scroll. Import as:
import { gsap } from "gsap";
import { ScrollTrigger, SplitText } from "gsap/all";
import Lenis from "lenis";
Register gsap.registerPlugin(ScrollTrigger, SplitText) inside DOMContentLoaded. (ScrollTrigger is only wired to Lenis for smooth scroll updates; there are no scroll-triggered animations in this component — everything is click/hover/mousemove driven.)
Layout / HTML
nav (fixed top bar)
.nav-toggle > p "Menu" (left — click target that opens/closes)
.nav-item > p "Archive" (right — decorative)
.menu-overlay (fixed full-viewport dark panel, clipped closed)
.menu-content (two meta-text columns, absolutely centered-ish)
.menu-col → stacked <p> lines separated by <br> (studio name, address, city, edition, contact email, phone)
.menu-col → stacked <p> lines separated by <br> (social links, language, credits/ref) — right-aligned
.menu-img (single small centered image)
img
.menu-links-wrapper (bottom-left horizontal row)
.menu-link × 5 (each links to "#")
a
span (visible copy) ← duplicate text
span (hover copy) ← same text, stacked absolutely on top
.link-highlighter (the lime tracking bar)
.container
section.hero > h1 "Shaping Ideas" (full-viewport light hero behind the overlay)
The five link labels are: Index, Persona, Biography, Work, Journal. Each .menu-link a contains the same word twice in two <span>s (span 1 = visible copy, span 2 = hover copy stacked directly on top).
Use a neutral fictional studio identity for the meta text (e.g. "Jam Area", a street/city, an edition/volume number, a contact email, a phone number, social platforms, a language, credits/imprint, a reference code). No real brands.
Styling
Fonts (Google Fonts): Anton (display) and DM Sans (UI/body, variable, italic + optical size + weight axes).
Color tokens:
--dark: #1e1e1e--light: #fefff8--accent: #9dfc11(lime green)body { background: #000; }
Type:
h1: Anton, uppercase,font-size: 10rem, weight 500,letter-spacing: -0.1rem,line-height: 0.9..hero h1width 70%.p, a: uppercase,font-size: 0.8rem, weight 600,line-height: 1, no underline,user-select: none..menu-link a: Anton,font-size: 10rem, weight 500,letter-spacing: -0.2rem,display: inline-block,overflow: hidden, color--light.img { width:100%; height:100%; object-fit: cover; }
Key positioning / structural CSS (these are load-bearing for the effect):
nav:position: fixed; top:0; left:0; width:100vw; padding:1rem;flexspace-between, color--light,mix-blend-mode: difference,z-index:2.nav p { padding:1rem; cursor:pointer; }.menu-overlay:position: fixed; inset:0 auto auto 0; width:100vw; height:100svh; overflow:hidden;background--dark, color--light,z-index:1,will-change: clip-path. Initial closed state:clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)(a degenerate rectangle collapsed onto the bottom edge — overlay invisible)..menu-content:position:absolute; top:45%; transform:translateY(-50%); width:100%; padding:2rem;flexspace-between/align-items:center..menu-col:nth-child(2){ text-align:right; }.menu-img:position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); width:150px;.menu-links-wrapper:position:absolute; left:0; bottom:0; width:max-content; padding:2rem;flex,gap:2rem,will-change: transform..menu-link:position:relative; overflow:hidden;will-change:transform. (Overflow toggles tovisibleonce open so the highlighter/hover chars aren't clipped — see GSAP section.).menu-link a span:nth-child(2):position:absolute; top:0; left:0;(hover copy stacked on the visible copy)..link-highlighter:position:absolute; bottom:0; left:0; width:400px; height:0.75rem;background--accent,will-change: transform, width. (JS overwrites width to the first link's measured width.).char(created by SplitText):position:relative; display:inline-block; will-change:transform;section:width:100vw; height:100svh;flex-centered, background--light, color--dark,padding:2rem..container:position:relative; z-index:0; will-change:transform, opacity;
GSAP effect (exhaustive)
0. Smooth scroll bootstrap
const lenis = new Lenis(); → lenis.on("scroll", ScrollTrigger.update); drive it from the GSAP ticker: gsap.ticker.add((time)=> lenis.raf(time*1000)); gsap.ticker.lagSmoothing(0).
1. SplitText per link + initial states
For each .menu-link a, grab its two <span>s. For each span run new SplitText(span, { type: "chars" }) and add class .char to every resulting char. For the second span only (index 1, the hover copy), gsap.set(split.chars, { y: "110%" }) so its characters sit one line-height below (hidden by the a's overflow:hidden).
Then set the closed/pre-open states:
gsap.set(menuContent, { y: "50%", opacity: 0.25 })gsap.set(menuImage, { scale: 0.5, opacity: 0.25 })gsap.set(menuLinks, { y: "150%" })←menuLinks= all.menu-link agsap.set(linkHighlighter, { y: "150%" })
2. Measure highlighter to first link
Read the first link's first span offsetWidth → set linkHighlighter.style.width to that px and seed currentHighlighterWidth = targetHighlighterWidth = linkWidth. Compute initialX = firstLink.getBoundingClientRect().left - menuLinksWrapper.getBoundingClientRect().left → seed currentHighlighterX = targetHighlighterX = initialX.
3. Open sequence (click .nav-toggle, isMenuOpen === false)
Guard with isMenuAnimating (bail if already animating; set true at start). These are independent gsap.to tweens fired simultaneously, each with its own delay — not a single timeline. All eases are expo.out.
container→{ y: "-40%", opacity: 0.25, duration: 1.25 }(pushes the hero back).menuOverlay→{ clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)", duration: 1.25 }— this grows the top two corners up to0%, wiping the panel open from the bottom edge upward to full-screen.onComplete:gsap.set(container, { y: "40%" }),gsap.set(".menu-link", { overflow: "visible" }), thenisMenuOpen = true; isMenuAnimating = false.menuContent→{ y: "0%", opacity: 1, duration: 1.5 }.menuImage→{ scale: 1, opacity: 1, duration: 1.5 }.menuLinks→{ y: "0%", duration: 1.25, stagger: 0.1, delay: 0.25 }— the five oversized links rise up out of the mask left-to-right.linkHighlighter→{ y: "0%", duration: 1, delay: 1 }— the lime bar drops in last.
4. Close sequence (click .nav-toggle, isMenuOpen === true)
Again independent simultaneous tweens, all expo.out, duration: 1.25 unless noted:
container→{ y: "0%", opacity: 1 }(hero returns).menuLinks→{ y: "-200%" }(links shoot up out of frame).menuContent→{ y: "-100%", opacity: 0.25 }.menuImage→{ y: "-100%", opacity: 0.5 }.menuOverlay→{ clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)" }— collapses the panel upward into the top edge.onCompleteresets everything to the closed state: overlay clip-path back to the bottom-collapsed polygonpolygon(0% 100%, 100% 100%, 100% 100%, 0% 100%),menuLinks y:"150%",linkHighlighter y:"150%",menuContent {y:"50%", opacity:0.25},menuImage {y:"0%", scale:0.5, opacity:0.25},.menu-link { overflow: "hidden" },menuLinksWrapper { x: 0 }and resetcurrentX = targetX = 0; thenisMenuOpen = false; isMenuAnimating = false.
5. Per-character hover swap (desktop only, window.innerWidth >= 1000)
On each .menu-link mouseenter: get the two spans' .char sets.
- visible copy chars →
gsap.to({ y: "-110%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" }) - hover copy chars →
gsap.to({ y: "0%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" })
On mouseleave:
- hover copy chars →
{ y: "110%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" } - visible copy chars →
{ y: "0%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" }
Net effect: the word rolls upward, the visible copy exiting through the top while the duplicate copy rides in from below, character by character.
6. Mouse-driven horizontal pan of the link strip (desktop only)
Listen for mousemove on .menu-overlay. Compute:
maxMoveLeft = 0,maxMoveRight = viewportWidth - menuLinksWrapperWidth(negative-space room to slide the strip fully right).sensitivityRange = viewportWidth * 0.5;startX = (viewportWidth - sensitivityRange)/2;endX = startX + sensitivityRange.mousePercentage:0ifmouseX <= startX,1ifmouseX >= endX, else(mouseX - startX) / sensitivityRange.targetX = maxMoveLeft + mousePercentage * (maxMoveRight - maxMoveLeft).
So only the central 50% of the screen width is active: sweeping the cursor across it pans the whole .menu-links-wrapper from its left-docked position to fully right-docked.
7. Highlighter target on hover (desktop only)
On each .menu-link mouseenter: targetHighlighterX = linkRect.left - menuWrapperRect.left and targetHighlighterWidth = (that link's first span).offsetWidth. On .menu-links-wrapper mouseleave: reset both targets back to the first link's position/width.
8. Lerp rAF loop (animate())
A requestAnimationFrame loop with lerpFactor = 0.05 interpolates each current toward its target:
currentX += (targetX - currentX) * 0.05
currentHighlighterX += (targetHighlighterX - currentHighlighterX) * 0.05
currentHighlighterWidth+= (targetHighlighterWidth- currentHighlighterWidth)* 0.05
Then each frame apply:
gsap.to(menuLinksWrapper, { x: currentX, duration: 0.3, ease: "power4.out" })gsap.to(linkHighlighter, { x: currentHighlighterX, width: currentHighlighterWidth, duration: 0.3, ease: "power4.out" })
This double-smooths (lerp + short power4.out tween) so the strip pan and the bar's slide/resize feel fluid and trailing.
Assets / images
1 image, role = *small centered menu image*. A single portrait-oriented black-and-white editorial photograph displayed at ~150px wide (object-fit: cover), sitting dead-center of the open overlay between the two text columns. The real asset is a tightly-cropped monochrome studio headshot of a young person facing the camera, short hair, dark top, shot against a plain light-grey seamless backdrop — dominant tones are soft greys and near-blacks with bright highlights, no color. The high-contrast grayscale reads cleanly against the dark panel. Provide one image; aspect ratio ~3:4 portrait.
Behavior notes
- Desktop-only interactions: the hover char-swap, mouse pan, and highlighter tracking all early-return when
window.innerWidth < 1000. - Responsive (
max-width: 1000px): heroh1becomeswidth:100%; font-size:4rem;.menu-contenttop:25%;.menu-imgand.link-highlighteraredisplay:none;.menu-links-wrapperswitches toflex-direction: column; gap: 0;.menu-link ashrinks tofont-size:4rem; letter-spacing:-0.05rem(links stack vertically). - Re-entrancy guard:
isMenuAnimatingblocks toggling mid-animation;isMenuOpentracks state. The open tween'sonCompleteflipscontainertoy:"40%"(so the hero peeks from below while open) and unhides link overflow. - The
animate()rAF loop runs continuously for the lifetime of the page.