Fullscreen Overlay Menu — Gooey SVG-Path Liquid Curtain + Elastic SplitText Links
Goal
Build a fixed navbar with a Menu / Close text toggle in the top-right corner that opens a fullscreen overlay menu. The signature effect: clicking the toggle drops a gooey liquid "curtain" down over the whole viewport by morphing a single SVG <path>'s d attribute through a quadratic-Bézier belly (it sags down like dripping paint before flattening to fill the screen), and as it lands the nav-link characters snap in one-by-one from far off the right edge with a springy elastic.out ease while the contact-info lines stagger up from below. Clicking Close reverses the whole thing: content fades, and the curtain retreats back up through an upward Bézier belly until it vanishes off the top. Entirely click-driven — no scroll, no autoplay.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the single GSAP plugin SplitText. No ScrollTrigger, no smooth-scroll library. Import as:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
gsap.registerPlugin(SplitText);
Wrap all code in a DOMContentLoaded listener.
Layout / HTML
.nav (fixed fullscreen layer, pointer-events:none, z-index 10)
.nav-logo > a > img (top-left wordmark, visible over the hero)
.nav-toggle (top-right click target — holds both toggle words stacked)
p.nav-toggle-menu "Menu" (white, shown when closed)
p.nav-toggle-close "Close" (dark, absolutely positioned over "Menu", opacity 0)
.menu (absolute fullscreen overlay, pointer-events:none until open)
svg.menu-bg-svg (the animated liquid curtain — see below)
path#menu-path
.menu-logo > a > img (top-left wordmark revealed inside the open menu, opacity 0)
.menu-col.menu-col-info (left column — contact block)
p "Get in touch" (small pink uppercase label)
h3 "studio@orbit.co"
h3 "+1 (437) 982 4412"
br
h6 "42 Mercer Street"
h6 "Toronto, ON M5V"
.menu-col.menu-col-links (right column — 6 big nav links)
a "work" a "services" a "about" a "insights" a "careers" a "contact"
section.hero (fullscreen background image behind everything)
The svg.menu-bg-svg MUST have viewBox="0 0 1131 861" and preserveAspectRatio="none" (so the path stretches to any viewport size). Its #menu-path starts with fill="#f0eeee" and an initial d="M1131,0 Q565.5,0 0,0 L0,0 L1131,0 Z" (a flat, zero-height line collapsed at the very top).
Use neutral, fictional copy — no real brand names. Nav-link labels (lowercase): work, services, about, insights, careers, contact.
Styling
Fonts (Google Fonts): Boldonse (the big display font for the nav links only) and Google Sans Flex (variable, everything else — body/UI/contact info). body { font-family: "Google Sans Flex", sans-serif; }.
Color tokens:
--base-100: #f0eeee(curtain fill + white nav text/logo over the hero — near-white)--base-200: #ff74ee(hot-pink accent — the "Get in touch" label only)--base-300: #222225(near-black — all menu text once the curtain is down)
Global reset: * { margin:0; padding:0; box-sizing:border-box; }. img { width:100%; height:100%; object-fit:cover; }.
Type:
p:text-transform: uppercase; font-size: 0.7rem; font-weight: 600; letter-spacing: 0.25rem;(Google Sans Flex).h3: Google Sans Flex, weight 450,font-size: clamp(1.5rem, 3vw, 3rem),line-height: 1.35,letter-spacing: -2%.h6: same family/weight,font-size: clamp(1rem, 1.25vw, 1.5rem)..menu-col a: Boldonse,font-size: clamp(2.5rem, 5vw, 5rem),line-height: 1.35,text-decoration:none,color: var(--base-300),display: block,width: max-content,overflow: visible(critical — lets the split characters sit far off-screen to the right before they slide in)..menu-col p:color: var(--base-200)(pink),margin-bottom: 1rem.
Structural / load-bearing CSS:
.hero:position: relative; width: 100%; height: 100svh;fullscreen background imageno-repeat 50% 50% / cover..nav:position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 10;..nav-logo,.menu-logo:position: absolute; top: 2rem; left: 2rem; width: 6rem; padding: 0.5rem;..nav-toggle:position: absolute; top: 2rem; right: 2rem; padding: 0.5rem; color: var(--base-100); cursor: pointer; pointer-events: all; z-index: 100;(.nav-logoand.nav-togglere-enablepointer-events: all)..nav-toggle-menu:color: var(--base-100);(white "Menu")..nav-toggle-close:position: absolute; top: 0.5rem; right: 0.5rem; color: var(--base-300); opacity: 0;(dark "Close" stacked exactly over "Menu")..menu:position: absolute; top: 0; left: 0; width: 100%; height: 100svh; padding: 2.5rem; display: flex; gap: 2rem; color: var(--base-300); pointer-events: none; z-index: 10;..menu.is-open { pointer-events: all; }(JS togglesis-open)..menu-bg-svg:position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: -1;(sits behind the menu columns; only the path shows)..menu-logo:opacity: 0;(revealed by JS)..menu-col:flex: 1; display: flex; flex-direction: column; justify-content: flex-end;(both columns bottom-align their content).will-change: transform, opacity;on.menu a .char, .menu-col h3, .menu-col h6, .menu-col p.
GSAP effect (exhaustive)
0. Read the SVG geometry & define the six path states
Read the dimensions off the viewBox so the path math is resolution-independent:
const svgWidth = menuBgSvg.viewBox.baseVal.width; // 1131
const svgHeight = menuBgSvg.viewBox.baseVal.height; // 861
const svgCenterX = svgWidth / 2; // 565.5
Every state is one filled quad: a top edge that is a quadratic Bézier curve (M rightX,y Q centerX,ctrlY leftX,y) closed down to two anchored corners. The ctrlY control point is what creates the gooey belly (below the endpoints = sag down, above = pull up). Define all six as template strings:
const svgPathStates = {
// top-anchored, zero height — curtain hidden above the top edge (rest state)
OPEN_HIDDEN: `M${svgWidth},0 Q${svgCenterX},0 0,0 L0,0 L${svgWidth},0 Z`,
// curtain has dropped ~40%: edge at y=345, control sagging DOWN to y=620 (drip belly)
OPEN_BULGE: `M${svgWidth},345 Q${svgCenterX},620 0,345 L0,0 L${svgWidth},0 Z`,
// flat bottom edge at y=861 — curtain fills the whole viewport
OPEN_FULL: `M${svgWidth},${svgHeight} Q${svgCenterX},${svgHeight} 0,${svgHeight} L0,0 L${svgWidth},0 Z`,
// full-screen but now anchored at the BOTTOM (moving edge is the flat top at y=0)
CLOSE_START: `M${svgWidth},0 Q${svgCenterX},0 0,0 L0,${svgHeight} L${svgWidth},${svgHeight} Z`,
// retreating up: top edge at y=350, control pulled UP to y=130 (belly rising)
CLOSE_BULGE: `M${svgWidth},350 Q${svgCenterX},130 0,350 L0,${svgHeight} L${svgWidth},${svgHeight} Z`,
// bottom-anchored, zero height — curtain gone off the bottom
CLOSE_HIDDEN: `M${svgWidth},${svgHeight} Q${svgCenterX},${svgHeight} 0,${svgHeight} L0,${svgHeight} L${svgWidth},${svgHeight} Z`,
};
gsap.set(menuBg, { attr: { d: svgPathStates.OPEN_HIDDEN } });
All path tweens animate attr: { d: ... } (GSAP interpolates the path string command-by-command; every state must have the same number/order of commands — M, Q, L, L, Z — so it morphs cleanly).
1. SplitText — chars of every nav link, parked far right
For each of the 6 .menu-col-links a, split into characters and hide them off-screen to the right:
const splits = [];
menuLinks.forEach((link) => {
const split = new SplitText(link, { type: "chars", charsClass: "char" });
splits.push(split);
gsap.set(split.chars, { opacity: 0, x: "750%" }); // 750% of each char's own width
});
Also collect the contact-info items (all direct children of .menu-col-info except the <br>) and park them below:
const menuInfoItems = [...document.querySelectorAll(".menu-col-info > *:not(br)")]; // p, h3, h3, h6, h6
gsap.set(menuInfoItems, { opacity: 0, y: 100 });
2. State guards
let isOpen = false; let isAnimating = false;. The toggle handler bails if isAnimating is true, else sets it true, flips isOpen, and calls openMenu() or closeMenu(). Each timeline clears isAnimating on complete.
3. openMenu()
Add is-open to .menu. First, cross-fade the toggle words (independent tweens, ease: "none"):
navToggleMenu→{ opacity: 0, duration: 0.25 }("Menu" out).navToggleClose→{ opacity: 1, duration: 0.25, delay: 0.25 }("Close" in, after the other fades).
Then a single timeline (onComplete → isAnimating = false) with this exact order + timing:
- Curtain drop, part 1 —
menuBg→{ attr:{ d: OPEN_BULGE }, duration: 0.5, ease: "power4.in" }. The flat top line accelerates down into the sagging belly. - Curtain drop, part 2 (chained, starts at t=0.5) —
menuBg→{ attr:{ d: OPEN_FULL }, duration: 0.5, ease: "power4.out" }. The belly flattens out to fill the screen. (Curtain total ≈ 0→1s.) - Menu logo at position
"-=0.75"(i.e. absolute t≈0.25) —menuLogo→{ opacity: 1, duration: 0.1, ease: "none" }. - Contact info at position
"-=0.35"(i.e. absolute t≈0.65) —menuInfoItems→{ opacity: 1, y: 0, duration: 0.75, ease: "power3.out", stagger: 0.075 }(label, then each line, rise up fromy:100). - Nav-link chars slide in at absolute position
0.45—menuLinksChars(splits.flatMap(s => s.chars)) →{ x: "0%", duration: 1.5, ease: "elastic.out(1, 0.25)", stagger: 0.01 }. Every character springs fromx:750%back to home with a bouncy overshoot, one after another 0.01s apart. - Nav-link chars fade in at absolute position
0.45(same start, runs concurrently) —menuLinksChars→{ opacity: 1, duration: 0.75, ease: "power2.out", stagger: 0.01 }.
So: the pink label + email + phone + address stagger up while the six words' letters come flying in from the right and settle with a spring — all landing as the curtain reaches full screen.
4. closeMenu()
First gsap.set(menuBg, { attr:{ d: CLOSE_START } }) — instantly re-anchor the (currently full) curtain to the bottom so its moving edge becomes the flat top. Cross-fade the toggle words back (ease: "none"):
navToggleClose→{ opacity: 0, duration: 0.3 }.navToggleMenu→{ opacity: 1, duration: 0.3, delay: 0.25 }.
Then a timeline whose onComplete fully resets state: remove is-open, gsap.set(menuBg, { attr:{ d: OPEN_HIDDEN } }), re-hide every char (opacity:0, x:"750%"), restore each link <a> to opacity:1, re-park menuInfoItems (opacity:0, y:100), isAnimating = false.
Timeline tweens:
menuLogo→{ opacity: 0, duration: 0.3 }.menuLinks(the<a>elements, not the chars) →{ opacity: 0, duration: 0.3 }at"<"(same time as #1).menuInfoItems→{ opacity: 0, duration: 0.3 }at"<".- Curtain retreat, part 1 —
menuBg→{ attr:{ d: CLOSE_BULGE }, duration: 0.5, ease: "power3.in" }at"<"(starts with the content fade). Top edge rises into an upward belly. - Curtain retreat, part 2 (chained) —
menuBg→{ attr:{ d: CLOSE_HIDDEN }, duration: 0.5, ease: "power3.out" }. Belly slides up and off the bottom edge until the curtain is gone.
Note the close curtain uses power3 (in then out), a touch softer than the power4 open; the belly geometry is inverted (control point above the edge instead of below).
5. Toggle wiring
navToggle.addEventListener("click", () => {
if (isAnimating) return;
isAnimating = true;
isOpen = !isOpen;
isOpen ? openMenu() : closeMenu();
});
Assets / images
- 1 fullscreen hero background (
bg.jpg), role = the page behind the closed menu. A close-up of swirling white marble — glossy, liquid-looking folds of near-white stone veined with soft grey and charcoal marbling that ripples in wavy diagonal bands. Overall very light/high-key (whites and pale greys dominant, thin darker grey veins), so the white logo and "Menu" toggle need a subtle shadow/contrast treatment to read against it. Landscape ~16:9,background-size: cover. - 2 wordmark logos (same file used twice), role = a simple lowercase sans-serif wordmark on a transparent background (SVG). One (
.nav-logo) shows white over the dark hero when closed; one (.menu-logo) is revealed top-left once the light curtain is down. Displayed ~6rem wide, roughly 3:1 (wide) aspect.
Behavior notes
- Purely click-driven off two independent (open / close) timelines — no scroll, no ScrollTrigger, no loops, no autoplay.
- The
isAnimatingflag debounces clicks so a new open/close can't start mid-animation. - The overlay is
pointer-events: noneuntil.is-open, so the closed hero stays interactive. - Every path state keeps the identical command sequence (
M Q L L Z) — required for GSAP'sattr:{d}string morph to interpolate without jumping. - Responsive (
max-width: 1000px):.menubecomesflex-direction: column-reverse(links on top, contact below) and.menu-col-linksgetsflex: 1.5.