Fullscreen Portfolio Menu — Clip-Path Overlay with Staggered Giant Links
Goal
Build a fullscreen overlay navigation for an editorial portfolio site. A burger button in the top-right toggles the menu: a single GSAP timeline sweeps a dark clip-path overlay down over the entire viewport, then staggers three oversized menu words up from behind a mask, grows a colored underline bar across the active item (animated via CSSRulePlugin on a ::after pseudo-element), and fades a social sub-nav up into place. Clicking the burger again plays the exact same timeline in reverse. The burger morphs into an X. The top nav uses mix-blend-mode: difference so its text/lines invert against whatever is behind them.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugin CSSRulePlugin (this is required — the active-item underline is a CSS ::after pseudo-element and can only be tweened through CSSRulePlugin.getRule(...)).
import gsap from "gsap";
import { CSSRulePlugin } from "gsap/CSSRulePlugin";
gsap.registerPlugin(CSSRulePlugin);
Wrap all JS in a DOMContentLoaded listener. No smooth-scroll library, no ScrollTrigger, no canvas/WebGL.
Layout / HTML
<div class="website-content">
<div class="hero-img"><img src="./hero.jpg" alt="" /></div>
</div>
<nav>
<div class="info"><p>Folio Vol. 1</p></div>
<div class="logo"><p><a href="#">The Elite Portfolio</a></p></div>
<div class="toggle-btn">
<button class="burger" onclick="this.classList.toggle('active');"></button>
</div>
</nav>
<div class="overlay">
<div class="overlay-menu">
<div class="menu-item"><div class="menu-item-name"><p id="active"><a href="#">Index</a></p></div></div>
<div class="menu-item"><div class="menu-item-name"><p><a href="#">Work</a></p></div></div>
<div class="menu-item"><div class="menu-item-name"><p><a href="#">About</a></p></div></div>
</div>
<div class="sub-nav">
<p><a href="#">Twitter</a></p><p>·</p>
<p><a href="#">Instagram</a></p><p>·</p>
<p><a href="#">Dribble</a></p><p>·</p>
<p><a href="#">Behance</a></p>
</div>
</div>
<script type="module" src="./script.js"></script>
Notes:
- The burger
<button>has an inlineonclick="this.classList.toggle('active');"that toggles the X shape via CSS — keep it. The JS adds a *separate* click listener on.burgerthat drives the GSAP timeline. - The first menu item's
<p>carriesid="active"— its::afteris the animated underline. - Stacking order (via z-index):
.website-content= 0 (base page),.overlaysits above it,nav= 2 (always on top).
Styling
Reset: * { margin: 0; padding: 0; box-sizing: border-box; }. html, body { width: 100%; height: 100%; }.
Palette (exact):
#cdc6be— warm greige / off-white (page background, most text, links)#141412— near-black (overlay background, hero border)#fff— white (menu text, burger lines)#c03f13— burnt orange-red (the active-item underline bar)
Fonts (by role): the small UI text (info, links) uses a clean neutral grotesque — name it "Neue Montreal" with a sans-serif fallback (substitute Inter/Helvetica Neue). The logo, the giant menu words and the sub-nav use an elegant high-contrast display serif — name it "Canopee" with a serif fallback (substitute any refined display serif such as Cormorant/Playfair if unavailable). The named families can stay declared even without @font-face; the layout is what matters.
Key rules:
a { text-decoration: none; color: #cdc6be; }.website-content—position: fixed; inset: 0; width/height 100%; z-index: 0; background: #cdc6be; padding: 2em;.hero-img—margin-top: 4em; width: 100%; height: 100%; border: 2px solid #141412;and itsimg { width:100%; height:100%; object-fit: cover; }nav—position: fixed; width: 100%; display: flex; justify-content: space-between; align-items: center; padding: 1.5em 2em; color: #cdc6be; mix-blend-mode: difference; z-index: 2;.nav > div { flex: 1; }. Themix-blend-mode: differenceis essential — it makes the nav text/burger invert against the light page and the dark overlay..logo—text-align: center; font-family: "Canopee";..logo p { position: relative; top: 0.225em; }..logo a { font-size: 30px; color: #cdc6be; }(drops to20pxunder@media (max-width: 900px))..toggle-btn { display: flex; justify-content: flex-end; }.burger—display: flex; justify-content: center; align-items: center; padding: 1.75em 2em 1.5em 2em; background: rgba(255,255,255,0); border-radius: 0.25em; outline: none; height: 20px; width: 28px; border: none; cursor: pointer; transition: all 250ms ease-out;.burger:before, .burger:after—content: ""; width: 40px; height: 2px; position: absolute; background: #fff; transition: all 250ms ease-out; will-change: transform;.:before { transform: translateY(-3px); },:after { transform: translateY(3px); }. When.burger.active::before { transform: translateY(0) rotate(45deg); },:after { transform: translateY(0) rotate(-45deg); }— the two bars cross into an X..overlay—position: fixed; inset: 0; width: 100vw; height: 100vh; display: flex; background: #141412; clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); will-change: transform;. The initial clip-path is collapsed flat against the top edge (zero height) so the overlay is invisible until animated..overlay-menu—position: fixed; inset: 0; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 1em; color: #fff;.menu-item—display: flex; cursor: pointer; clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);. This rectangular clip-path is the mask that hides each word while it sits pushed down (y: 225) so it can rise into view..menu-item p—position: relative; text-align: center; font-family: "Canopee"; font-size: 15vw; line-height: 80%; will-change: transform; transition: letter-spacing 0.3s;. On:hover,letter-spacing: 0.075em;..menu-item p#active::after—content: ""; position: absolute; top: 45%; left: 0; transform: translateY(-50%); background: #c03f13; width: 0%; height: 12px;. Starts atwidth: 0%— GSAP grows it to 100%..menu-item a { color: #cdc6be; }.sub-nav—position: absolute; bottom: 5%; left: 50%; transform: translateX(-50%); display: flex; gap: 0.5em; opacity: 0;..sub-nav p { font-family: "Canopee"; font-size: 20px; color: #cdc6be; }.
GSAP effect (exhaustive)
State: a single boolean isOpen = false and one paused timeline built once on load.
Setup (runs immediately on DOMContentLoaded)
- Grab the underline pseudo-element rule:
const activeItemIndicator = CSSRulePlugin.getRule(".menu-item p#active::after");
const toggleButton = document.querySelector(".burger");- Set the closed/initial state of the giant words:
gsap.set(".menu-item p", { y: 225 });— all three words pushed 225px down, hidden behind each.menu-item's clip-path mask. const timeline = gsap.timeline({ paused: true });
Timeline (4 tweens, in this exact order and position)
Tween 1 — overlay wipes down. Target .overlay.
clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)"(from the CSS initial collapsed-top polygon → full rectangle, i.e. the dark panel unrolls downward covering the viewport).duration: 1.5,ease: "power4.inOut". Inserted at the timeline start (t = 0).
Tween 2 — menu words rise, staggered. Target .menu-item p.
y: 0(from the presety: 225→ 0, so each word slides up into its mask).duration: 1.5,stagger: 0.2(Index, then Work, then About, 0.2s apart),ease: "power4.out".- Position parameter
"-=1"— starts 1s before the previous tween ended, i.e. at t ≈ 0.5s, overlapping the overlay wipe.
Tween 3 — active underline grows. Target activeItemIndicator (the CSSRulePlugin rule).
width: "100%"(from0%→100%, the burnt-orange bar draws left→right across the "Index" word).duration: 1,ease: "power4.out",delay: 0.5.- Position parameter
"<"— inserted at the *start* of Tween 2 (t ≈ 0.5s); with its owndelay: 0.5it effectively begins around t ≈ 1.0s and finishes at ≈ 2.0s.
Tween 4 — social sub-nav fades up. Target .sub-nav.
bottom: "10%"(from5%→10%, rises slightly) andopacity: 1(from0→1).duration: 0.5,delay: 0.5.- Position parameter
"<"— inserted at the start of Tween 3 (t ≈ 0.5s); withdelay: 0.5it effectively begins around t ≈ 1.0s.
Trigger
Add a click listener on .burger:
toggleButton.addEventListener("click", () => {
if (isOpen) timeline.reverse();
else timeline.play();
isOpen = !isOpen;
});
So the first click plays the timeline forward (menu opens), the next click reverses it (everything retracts along the same eases), and so on. The inline onclick on the button independently toggles the .active class that morphs the burger into an X (250ms CSS transition), in sync with the toggle.
Assets / images
One image: hero.jpg — a large, moody, editorial photograph (portrait mood works well) filling the bordered hero frame full-bleed via object-fit: cover. It sits on the base page behind the overlay and is only visible when the menu is closed. Any single high-quality editorial/fashion-style photo works; aspect ratio is flexible since it is cropped to fill the frame.
Behavior notes
- Desktop-oriented; the
15vwmenu type and centered flex layout scale down gracefully.@media (max-width: 900px)only shrinks the logo to20px. - The whole effect is a single reusable paused timeline — no re-creation per click, just
play()/reverse(). - No infinite loops, no scroll hijacking; lightweight and mobile-safe.
will-change: transformhints are set on the overlay, menu words and burger bars for smoothness.