All components

Fullscreen Portfolio Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen overlay navigation that toggles open on clicking the burger button: a GSAP timeline sweeps a clip-path polygon over the whole viewport on a power4.inOut ease, then staggers the oversized menu items up from a y offset with power4.out, grows a CSSRulePlugin-animated underline on the active item, and fades the social sub-nav up into place. Clicking again reverses the same timeline.

How it's built

Categorymenu
Techgsap
GSAP pluginsCSSRulePlugin
Complexitysection
Performance costlight
Mobile-safeyes

fullscreen-menu overlay clip-path burger-toggle staggered-reveal cssruleplugin gsap editorial minimal

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 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 inline onclick="this.classList.toggle('active');" that toggles the X shape via CSS — keep it. The JS adds a *separate* click listener on .burger that drives the GSAP timeline.
  • The first menu item's <p> carries id="active" — its ::after is the animated underline.
  • Stacking order (via z-index): .website-content = 0 (base page), .overlay sits 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-contentposition: fixed; inset: 0; width/height 100%; z-index: 0; background: #cdc6be; padding: 2em;
  • .hero-imgmargin-top: 4em; width: 100%; height: 100%; border: 2px solid #141412; and its img { width:100%; height:100%; object-fit: cover; }
  • navposition: 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; }. The mix-blend-mode: difference is essential — it makes the nav text/burger invert against the light page and the dark overlay.
  • .logotext-align: center; font-family: "Canopee";. .logo p { position: relative; top: 0.225em; }. .logo a { font-size: 30px; color: #cdc6be; } (drops to 20px under @media (max-width: 900px)).
  • .toggle-btn { display: flex; justify-content: flex-end; }
  • .burgerdisplay: 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:aftercontent: ""; 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.
  • .overlayposition: 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-menuposition: fixed; inset: 0; width: 100vw; height: 100vh; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 1em; color: #fff;
  • .menu-itemdisplay: 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 pposition: 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::aftercontent: ""; position: absolute; top: 45%; left: 0; transform: translateY(-50%); background: #c03f13; width: 0%; height: 12px;. Starts at width: 0% — GSAP grows it to 100%.
  • .menu-item a { color: #cdc6be; }
  • .sub-navposition: 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)
  1. Grab the underline pseudo-element rule:

const activeItemIndicator = CSSRulePlugin.getRule(".menu-item p#active::after");

  1. const toggleButton = document.querySelector(".burger");
  2. 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.
  3. 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 preset y: 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%" (from 0%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 own delay: 0.5 it effectively begins around t ≈ 1.0s and finishes at ≈ 2.0s.

Tween 4 — social sub-nav fades up. Target .sub-nav.

  • bottom: "10%" (from 5%10%, rises slightly) and opacity: 1 (from 01).
  • duration: 0.5, delay: 0.5.
  • Position parameter "<" — inserted at the start of Tween 3 (t ≈ 0.5s); with delay: 0.5 it 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 15vw menu type and centered flex layout scale down gracefully. @media (max-width: 900px) only shrinks the logo to 20px.
  • 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: transform hints are set on the overlay, menu words and burger bars for smoothness.

Images

This component ships with 1 reference asset, 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/nm-menu-rebuild/hero.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--ink, --bone, --ash, --ember, --paper. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 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 *, html, 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 waits for DOMContentLoaded, 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 menu opens and closes correctly on the first click 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. Run this component's setup twice against the same DOM and you get two paused timelines built from the same starting pose (gsap.set(".menu-item p", { y: 225 })), and two click listeners stacked on the same .burger, each closed over its own isOpen. One click now fires both handlers at once: each still reads its own fresh isOpen as false, so both call .play() instead of one playing and one reversing — the .overlay clip-path wipe restarts mid-sweep, the three staggered .menu-item p words jump back down and rise a second time, and the burnt-orange underline tween fights itself for the same width on activeItemIndicator. None of this reproduces 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 body — the CSSRulePlugin.getRule lookup, the gsap.set, the timeline build, the .burger click wiring — never runs, and the menu is simply inert with nothing to debug. Delete the DOMContentLoaded listener and move its body directly into a useEffect with an empty dependency array. gsap.registerPlugin(CSSRulePlugin) is the one line that stays outside it, at module scope — it touches no DOM, and re-registering the plugin on every mount is harmless but pointless.

*(2) Element lookups* — document.querySelector(".burger") assumes this component owns the document; resolve it off a root ref instead (rootRef.current.querySelector(".burger")). The three selector-string tween targets built inside the timeline — .overlay, .menu-item p, .sub-nav — don't need that same treatment: gsap.context's second argument rewrites selector-string targets to the root automatically. Only .burger, reached through a bare document.querySelector rather than as a tween target, needs the manual scoping. During the StrictMode remount two copies of .burger exist for an instant, and an unscoped query has no way to tell which one belongs to the copy on its way out.

*(3) Cleanup* — Wrap the CSSRulePlugin.getRule call, the initial gsap.set, and the timeline build in a gsap.context scoped to the root ref. Keep the timeline and the isOpen flag in the effect's own closure rather than useState — neither ever drives a render, and a fresh isOpen reset to false on every effect run is exactly what a clean remount wants. Give the click handler a name so it can be removed explicitly:

useEffect(() => {
  let timeline;

  const ctx = gsap.context(() => {
    const activeItemIndicator = CSSRulePlugin.getRule(
      ".menu-item p#active::after"
    );
    gsap.set(".menu-item p", { y: 225 });
    timeline = gsap.timeline({ paused: true });
    // the same four tweens, in the same order and positions, targeting
    // .overlay, .menu-item p, activeItemIndicator, and .sub-nav
  }, rootRef);

  let isOpen = false;
  const toggleButton = rootRef.current.querySelector(".burger");
  const handleClick = () => {
    if (isOpen) timeline.reverse();
    else timeline.play();
    isOpen = !isOpen;
  };
  toggleButton.addEventListener("click", handleClick);

  return () => {
    toggleButton.removeEventListener("click", handleClick);
    ctx.revert();
  };
}, []);

ctx.revert() undoes the timeline's four tweens and the inline clip-path/transform/width values they wrote, plus the y offset gsap.set applied to the three words — everything the factory touched during its synchronous pass. It does not touch handleClick: addEventListener isn't a GSAP object, so the listener needs its own removeEventListener, keyed off the exact function reference passed in, run before ctx.revert(). Nothing here needs self.add, either — unlike a menu whose open/close handler builds new tweens on every click, this one only calls .play()/.reverse() on the single timeline the factory already built, so there's no later tween creation for the context to miss.

CSSRulePlugin.getRule is not a DOM query, and root-ref scoping doesn't apply to it the way it does to .burger: it searches document.styleSheets for a rule whose selector text matches a literal string, against the real, compiled CSS. If this component's styles ship through CSS Modules, .menu-item gets renamed at build time (for example .menu-item_a3f1b), and ".menu-item p#active::after" no longer matches anything the browser actually parsed — getRule returns null, and the third tween has nothing to grow the underline bar on, silently. The #active id is untouched by CSS Modules either way, so only the .menu-item class piece is at risk; keep this rule's declaration in a global, un-scoped stylesheet, or write it inside a :global(...) block, so the literal string the effect passes to getRule and the one the browser registered are the same text.