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 imageno-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%;flexjustify-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 aty:0, bottom two also aty:0), making the panel invisible until the timeline opens it..menu-close-btn { cursor:pointer; }..menu-nav: flexjustify-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 imageno-repeat 50% 50%,background-size: cover; border-radius: 4px;(starts fully collapsed to 0 height)..video-details:width:100%;flexjustify-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: flexjustify-content: space-between;..socials: flexgap: 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-overlayclip-pathanimates from the degenerate closed polygonpolygon(0 0, 100% 0, 100% 0, 0 0)(zero-height strip at top) to the full rectanglepolygon(0 0, 100% 0, 100% 100%, 0 100%), so the lime panel wipes open downward from the top edge over 1s onpower2.out. - B — links/CTA reveal. A
.from()on all.menu-linkand.btnelements: they animate fromopacity:0, y:60to their natural state (opacity:1, y:0), i.e. they fade in while sliding up 60px.duration: 0.75,ease: "power1.inOut",stagger: 0.05so 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-previewheightanimates0px → 200pxover 1s onpower2.out, so the thumbnail expands open from a zero-height sliver. - D — divider draw.
.menu-dividerwidthanimates0% → 100%over 2s onpower4.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-eventstoall(so its links/close button become clickable) andtl.play()— panel wipes down, links fade up, thumbnail grows, divider draws. - Close: set
pointer-eventsback tononeandtl.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.2 → node_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: coverat50% 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-previewpanel 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-eventsis toggled toallon open so only the open menu is interactive. - Responsive (
max-width: 900px):.menu-overlaygetsheight: 100vh(fullscreen on mobile instead of a content-height drop-down);.menu-colsswitches todisplay: block(columns stack);.video-previewgetsheight: 125px;.menu-link ashrinks tofont-size: 30px. - Hover niceties (pure CSS, not GSAP): each
.menu-linkgrows a 2px underline left-to-right; the.btnfills 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:--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
*,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.