All components

Rejoice Fullscreen Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

A fullscreen overlay navigation menu that opens on clicking the 'Menu' button and closes on 'Close'. A single paused GSAP timeline plays/reverses: it animates the overlay clip-path open, staggers the nav links and CTA up with a fade, grows the video preview thumbnail and draws the footer divider from 0 to 100% width.

How it's built

Categorymenu
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

menu fullscreen-overlay clip-path gsap-timeline staggered-reveal hamburger navigation vanilla-js 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

Clip-Path Drop-Down Overlay Menu — Top Panel Wipe + Staggered Link Reveal + Growing Reel Thumbnail + Divider Line Draw

Goal

Build a page with a fixed top navbar (logo + a "Menu" text button) sitting over a full-bleed photographic background. Clicking Menu opens a lime-green overlay panel that drops down from the top edge via an animated clip-path, and clicking Close reverses it. The signature effect is a single paused, reversible GSAP timeline whose four tweens all fire together at time 0: (1) the overlay's clip-path polygon expands from a zero-height top strip to the full panel (wipes down from top); (2) the nav links + CTA button fade up from below with a small stagger; (3) the reel thumbnail grows in height from 0 to 200px; and (4) the footer divider draws itself from 0% to 100% width. Open plays the timeline forward, Close plays the exact same timeline in reverse. It is entirely click-driven — no scroll, no autoplay.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no GSAP plugins, no ScrollTrigger, no smooth-scroll library. Import as:

import gsap from "gsap";

Icons: Phosphor Icons (web) — see Icons below for the exact version and where to get it. Two filled glyphs: a play inside .video-preview (<i class="ph-fill ph-play"></i>) and a play-circle in .video-details (<i class="ph-fill ph-play-circle"></i>). If unavailable, any small play-circle icon substitutes.

Layout / HTML

.container                              (position: relative, full width/height)
  .website-content                      (the underlying page copy — sits behind everything)
    h1 / p ...                          (a few heading + paragraph blocks of filler copy)
  nav                                   (fixed top bar, flex space-between, white text)
    .logo            "Impulse"
    .menu-open-btn   "Menu"             (click target → open)
  .menu-overlay                         (fixed top green panel; clip-path collapsed closed)
    .menu-nav                           (flex space-between)
      .menu-logo     "Impulse"
      .menu-close-btn "Close"           (click target → close)
    .menu-cols                          (flex row of two .col)
      .col
        .video                          (width: 50%)
          .video-preview               (reel thumbnail bg; height 0 closed → grows)
            span.video-play → <i ph-fill ph-play>
          .video-details               (flex space-between)
            p  → <i ph-fill ph-play-circle> "Play reel"
            p  "-01:18"
      .col
        .menu-link > a  "Home"
        .menu-link > a  "Workplace"
        .menu-link > a  "Services & Models"
        .menu-link > a  "Our Story"
        .menu-link > a  "Contact"
        .btn      > a  "Take a seat"    (CTA button)
    .menu-footer
      .menu-divider                     (width 0% → draws to 100%)
      .menu-footer-copy                 (flex space-between)
        .slogan  > p  "Tomorrow's Brands, Today.™"
        .socials → a "Twitter"  a "Instagram"  a "LinkedIn"

Use the neutral, fictional brand name "Impulse" and the labels above — no real brand names.

Styling

Font: "Neue Montreal", sans-serif (a clean neutral grotesque; substitute a similar sans such as Inter/Helvetica if the face is unavailable). Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Color tokens (CSS custom properties):

  • --color-menu-overlay: #a0e806 (bright lime/chartreuse — the overlay panel fill)
  • --color-menu-text: #000 (black — all text/links/icons inside the overlay)
  • --color-divider: #000000 (black — the footer divider line)

Structural / load-bearing CSS:

  • html, body: width:100%; height:100%; background = the hero image no-repeat 50% 50%, background-size: cover; overflow-x: hidden. Links: text-decoration:none; color: var(--color-menu-text);. i { position: relative; top: 1px; }.
  • .container: position: relative; width:100%; height:100%;.
  • nav: position: fixed; top:0; width:100%; flex justify-content: space-between; padding: 2em; color:#fff; z-index:0;. .menu-open-btn { cursor:pointer; }.
  • .website-content: position:absolute; top:0; width:100%; margin-top:10em; padding:6em 2em; color:#fff; background: rgba(0,0,0,0.5); z-index:0;. h1 { font-size:80px; font-weight:500; margin-bottom:0.25em; } p { font-size:24px; font-weight:400; }.
  • .menu-overlay: position: fixed; top:0; width:100%; padding: 2em; background: var(--color-menu-overlay); pointer-events: none; clip-path: polygon(0 0, 100% 0, 100% 0, 0 0); z-index: 1000 !important;. Note: no explicit height on desktop, so the panel is only as tall as its content (a drop-down, not fullscreen); the closed clip-path is a degenerate zero-height rectangle pinned to the top edge (top two points at y:0, bottom two also at y:0), making the panel invisible until the timeline opens it. .menu-close-btn { cursor:pointer; }.
  • .menu-nav: flex justify-content: space-between; margin-bottom: 1em; color: var(--color-menu-text);.
  • .menu-cols: display: flex;. .menu-cols > div { flex:1; padding: 1em 0; }.
  • .video: width: 50%;. .video-preview: width:100%; height:0px; background = the reel thumbnail image no-repeat 50% 50%, background-size: cover; border-radius: 4px; (starts fully collapsed to 0 height). .video-details: width:100%; flex justify-content: space-between; padding: 0.5em 0; color: var(--color-menu-text);.
  • .menu-link: position: relative; width: max-content;. .menu-link a { font-size: 40px; }. Hover underline: .menu-link:after { content:""; position:absolute; top:100%; left:0; width:0; height:2px; background: var(--color-menu-text); transition: 0.3s all; } and .menu-link:hover:after { width:100%; }.
  • .btn: position: relative; margin: 2em 0; border: 1px solid var(--color-menu-text); width: max-content; padding: 1.25em 2.5em; border-radius: 4px; overflow: hidden; cursor: pointer;. Fill-on-hover: .btn:before { content:""; position:absolute; top:0; left:0; width:0; height:100%; background: var(--color-menu-text); transition: 0.3s all; z-index:-1; }, .btn:hover:before { width:100%; }, .btn:hover a { color: var(--color-menu-overlay); } (text flips to lime as the black fill sweeps in).
  • .menu-footer: flex column, color: var(--color-menu-text);.
  • .menu-divider: width: 0%; height: 1px; background: var(--color-divider); margin: 1em 0; (starts at zero width).
  • .menu-footer-copy: flex justify-content: space-between;. .socials: flex gap: 1em;.

GSAP effect (exhaustive)

1. Build one paused timeline at DOMContentLoaded

Everything runs inside document.addEventListener("DOMContentLoaded", …). Create a single paused timeline; append four tweens, three of them positioned at "<" so they all begin at time 0 together:

let tl = gsap.timeline({ paused: true });

// Tween A — overlay clip-path wipe open (appended at t = 0)
tl.to(".menu-overlay", {
  duration: 1,
  clipPath: "polygon(0 0, 100% 0, 100% 100%, 0 100%)",
  ease: "power2.out",
});

// Tween B — nav links + CTA fade up (position "<" = start of previous tween = t = 0)
tl.from(".menu-link, .btn", {
  opacity: 0,
  y: 60,
  stagger: 0.05,
  duration: 0.75,
  ease: "power1.inOut",
}, "<");

// Tween C — reel thumbnail grows (position "<" = t = 0)
tl.to(".video-preview", {
  duration: 1,
  height: "200px",
  ease: "power2.out",
}, "<");

// Tween D — footer divider draws (position "<" = t = 0)
tl.to(".menu-divider", {
  duration: 2,
  width: "100%",
  ease: "power4.out",
}, "<");

Exact behavior of each tween:

  • A — clip-path panel wipe. .menu-overlay clip-path animates from the degenerate closed polygon polygon(0 0, 100% 0, 100% 0, 0 0) (zero-height strip at top) to the full rectangle polygon(0 0, 100% 0, 100% 100%, 0 100%), so the lime panel wipes open downward from the top edge over 1s on power2.out.
  • B — links/CTA reveal. A .from() on all .menu-link and .btn elements: they animate from opacity:0, y:60 to their natural state (opacity:1, y:0), i.e. they fade in while sliding up 60px. duration: 0.75, ease: "power1.inOut", stagger: 0.05 so the six items (5 links + CTA) cascade up 0.05s apart top-to-bottom. Because this is a .from() on a paused timeline sitting at t=0, the items render pre-hidden (opacity 0, pushed down) while the menu is closed.
  • C — reel thumbnail grow. .video-preview height animates 0px → 200px over 1s on power2.out, so the thumbnail expands open from a zero-height sliver.
  • D — divider draw. .menu-divider width animates 0% → 100% over 2s on power4.out — a slow line that draws left-to-right and is the last thing to finish (the timeline's total length is 2s, set by this longest tween).

All four start simultaneously at t=0; the timeline runs 0 → ~2s. There is no delay, no labels beyond the "<" position params.

2. Open / close = play / reverse the same timeline
function openMenu() {
  document.querySelector(".menu-overlay").style.pointerEvents = "all";
  tl.play();
}
function closeMenu() {
  document.querySelector(".menu-overlay").style.pointerEvents = "none";
  tl.reverse();
}
document.querySelector(".menu-open-btn").addEventListener("click", openMenu);
document.querySelector(".menu-close-btn").addEventListener("click", closeMenu);

tl.reverse();   // prime the timeline in its reversed/closed state at init
  • Open: flip the overlay's pointer-events to all (so its links/close button become clickable) and tl.play() — panel wipes down, links fade up, thumbnail grows, divider draws.
  • Close: set pointer-events back to none and tl.reverse() — the identical timeline runs backwards: divider retracts, thumbnail collapses, links fade back down, panel wipes up and re-hides.
  • Init: call tl.reverse() once at the end. On a paused timeline sitting at time 0 this sets the play direction to reverse and holds it at the start, ensuring the .from() link tween is committed to its hidden start values and the menu begins fully closed.

Icons

This component uses Phosphor Icons (web) 2.1.2<i class="ph-fill ph-play">, <i class="ph-fill ph-play-circle">. It is an icon font, so all it needs is the stylesheet for the fill weight; there is no JavaScript involved.

The demo serves its own copy, pinned and content-hashed. Point at it directly, or download it and serve it from your own origin:

https://motionprompts.dev/c/_vendor/phosphor-icons-web-2.1.2.6e87cf5a/fill/style.css
https://motionprompts.dev/c/_vendor/phosphor-icons-web-2.1.2.6e87cf5a/fill/Phosphor-Fill.woff2
<link rel="stylesheet" href="/c/_vendor/phosphor-icons-web-2.1.2.6e87cf5a/fill/style.css" />

The stylesheet references its .woff2 with a relative URL, so keep the two files side by side if you host them yourself (npm i @phosphor-icons/web@2.1.2node_modules/@phosphor-icons/web/src/fill/). Do not use the package's own index.js: it injects the stylesheets from cdn.jsdelivr.net.

Any equivalent icon set is an acceptable substitute — keep the element and its selectors so the animation still has something to target.

Assets / images

  • 1 hero image — role: *full-bleed page background behind the site content and navbar* (set on body, background-size: cover at 50% 50%). A moody, atmospheric photographic backdrop (dim/low-key so the white nav text and the semi-transparent dark content block read on top). Landscape, ~16:9 (e.g. 1600×900 or larger).
  • 1 reel thumbnail — role: *background of the .video-preview panel inside the open overlay* (background-size: cover, 50% 50%, border-radius: 4px). A single video-still / showreel frame; it renders inside a half-width column and grows to 200px tall, so a landscape ~16:9 still works best.

No logos or icon images are needed beyond the two Phosphor glyphs (ph-play, ph-play-circle); the "Impulse" logo and slogan are plain text.

Behavior notes

  • Entirely click-driven — no scroll, no ScrollTrigger, no autoplay, no loops. One paused, reversible timeline is the single source of truth for open/close.
  • The closed overlay is pointer-events: none (never blocks the page); pointer-events is toggled to all on open so only the open menu is interactive.
  • Responsive (max-width: 900px): .menu-overlay gets height: 100vh (fullscreen on mobile instead of a content-height drop-down); .menu-cols switches to display: block (columns stack); .video-preview gets height: 125px; .menu-link a shrinks to font-size: 30px.
  • Hover niceties (pure CSS, not GSAP): each .menu-link grows a 2px underline left-to-right; the .btn fills black from the left while its text flips to lime.

Images

This component ships with 2 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/rejoice-menu/hero.jpg
https://motionprompts.dev/c/rejoice-menu/showreel-thumbnail.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--color-menu-overlay, --color-menu-text, --color-accent, --color-divider. 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 DOMContentLoaded listener that builds a single paused timeline once, wires it to two buttons with plain addEventListener calls, and never expects a second copy of itself to exist. React withdraws that assumption quietly — the menu still opens on the first click, and the damage only shows up on a StrictMode remount or an ordinary route-away-and-back.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen, and the double-invoke does not remount the DOM subtree in between — .menu-open-btn and .menu-close-btn are the same nodes on both passes. Nothing in this script ever calls removeEventListener, so a naive port leaves two click listeners on each button, each pair closing over its own tl built by its own effect run. One click on "Menu" now calls .play() on both timelines at once: two clip-path wipes targeting the same .menu-overlay, two staggered rises on the same .menu-link/.btn nodes, two .video-preview grows and two .menu-divider draws, all fighting over the same handful of CSS properties. This is not a development-only curiosity — because nothing here ever tears itself down, the identical doubling happens on every real visit back to this route, one more stacked pair of listeners each time.

*(1) The entry point* — The script waits for DOMContentLoaded with no readyState check first. By the time a React component mounts, that event has already fired, so the listener body — building tl, defining openMenu/closeMenu, wiring them to the two buttons, and the closing tl.reverse() that primes the menu shut — never runs. Delete the listener and move its body into a useEffect with an empty dependency array.

*(2) Element lookups* — Two different things are being resolved here. The four tween targets (.menu-overlay, .menu-link, .btn, .video-preview, .menu-divider) are selector strings handed straight to tl.to()/tl.from(), not document.querySelector calls — passing the root ref as gsap.context's second argument is what confines GSAP's own resolution of those strings to this component's subtree. The other four lookups — .menu-open-btn and .menu-close-btn for attaching the click listeners, and the two separate document.querySelector(".menu-overlay") calls inside openMenu/closeMenu that only exist to flip pointerEvents — are raw DOM calls GSAP never sees, so the context scope does nothing for them. Resolve all four off the root ref explicitly.

*(3) Cleanup* — Every tween runs during the effect's own synchronous pass — tl is built once, up front, not recreated inside openMenu/closeMenu — so a plain gsap.context wrapped around the whole body captures the timeline for free, no self.add() needed. openMenu/closeMenu themselves must still be reachable from outside the factory, though, or the cleanup has nothing to pass to removeEventListener:

useEffect(() => {
  const menuOpenBtn = rootRef.current.querySelector(".menu-open-btn");
  const menuCloseBtn = rootRef.current.querySelector(".menu-close-btn");
  let openMenu, closeMenu;

  const ctx = gsap.context(() => {
    const tl = gsap.timeline({ paused: true });
    // the four "<"-positioned tweens exactly as above

    openMenu = () => {
      rootRef.current.querySelector(".menu-overlay").style.pointerEvents = "all";
      tl.play();
    };
    closeMenu = () => {
      rootRef.current.querySelector(".menu-overlay").style.pointerEvents = "none";
      tl.reverse();
    };

    menuOpenBtn.addEventListener("click", openMenu);
    menuCloseBtn.addEventListener("click", closeMenu);
    tl.reverse();
  }, rootRef);

  return () => {
    menuOpenBtn.removeEventListener("click", openMenu);
    menuCloseBtn.removeEventListener("click", closeMenu);
    ctx.revert();
  };
}, []);

ctx.revert() undoes exactly what the four tweens wrote during that synchronous pass: the .menu-overlay clip-path, the opacity/transform GSAP put on .menu-link and .btn, the .video-preview height, and the .menu-divider width. It does not touch the two things attached by hand, outside of any tween: the click listeners on .menu-open-btn/.menu-close-btn, and the pointerEvents toggle inside openMenu/closeMenu. Skip the explicit removeEventListener pair above and the StrictMode remount — or the next real visit to this route — leaves both buttons with one extra listener apiece, which is the doubled-timeline failure described at the top of this section. The closing tl.reverse() needs no separate handling: it only flips the play direction of the timeline ctx.revert() already knows about, it does not create anything new.