All components

Poppr Gooey Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

Fullscreen overlay menu with a gooey liquid-curtain reveal: clicking the Menu/Close toggle morphs an SVG path's d attribute through a quadratic-Bezier bulge into a full-screen sheet (power4.in/out, reversed with power3 on close), while SplitText characters of each nav link snap in from x:750% with an elastic.out(1, 0.25) ease and staggered fades, and contact info items stagger up from y:100.

How it's built

Categorymenu
Techgsap
GSAP pluginsSplitText
Complexitysection
Performance costlight
Mobile-safeyes

overlay menu svg-morph gooey split-text elastic stagger path-animation

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 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 image no-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-logo and .nav-toggle re-enable pointer-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 toggles is-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:

  1. Curtain drop, part 1menuBg{ attr:{ d: OPEN_BULGE }, duration: 0.5, ease: "power4.in" }. The flat top line accelerates down into the sagging belly.
  2. 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.)
  3. Menu logo at position "-=0.75" (i.e. absolute t≈0.25) — menuLogo{ opacity: 1, duration: 0.1, ease: "none" }.
  4. 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 from y:100).
  5. Nav-link chars slide in at absolute position 0.45menuLinksChars (splits.flatMap(s => s.chars)) → { x: "0%", duration: 1.5, ease: "elastic.out(1, 0.25)", stagger: 0.01 }. Every character springs from x:750% back to home with a bouncy overshoot, one after another 0.01s apart.
  6. 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:

  1. menuLogo{ opacity: 0, duration: 0.3 }.
  2. menuLinks (the <a> elements, not the chars) → { opacity: 0, duration: 0.3 } at "<" (same time as #1).
  3. menuInfoItems{ opacity: 0, duration: 0.3 } at "<".
  4. Curtain retreat, part 1menuBg{ attr:{ d: CLOSE_BULGE }, duration: 0.5, ease: "power3.in" } at "<" (starts with the content fade). Top edge rises into an upward belly.
  5. 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 isAnimating flag debounces clicks so a new open/close can't start mid-animation.
  • The overlay is pointer-events: none until .is-open, so the closed hero stays interactive.
  • Every path state keeps the identical command sequence (M Q L L Z) — required for GSAP's attr:{d} string morph to interpolate without jumping.
  • Responsive (max-width: 1000px): .menu becomes flex-direction: column-reverse (links on top, contact below) and .menu-col-links gets flex: 1.5.