All components

Meristem Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

Fullscreen overlay menu toggled by a hamburger button: a paused GSAP timeline scales four stacked green background layers in from the top (scaleY 0 to 1, 0.1s stagger, power3.inOut), then wipes the nav panel in with a clip-path polygon reveal while SplitText line-masked links slide up from y:100% with a 0.05s stagger (power3.out). Clicking again reverses the timeline and resets the link lines on close.

How it's built

Categorymenu
Techgsap
GSAP pluginsSplitText
Complexitysection
Performance costlight
Mobile-safeyes

overlay menu hamburger split-text masked-lines clip-path stacked-layers stagger

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

Stacked Layers Overlay Menu — Curtain-Drop Background Stack + Clip-Path Panel Wipe + Masked Line Reveal

Goal

Build a fixed top navbar with a hamburger toggler that opens a fullscreen overlay menu. The signature effect is a three-part open sequence off a single paused GSAP timeline: (1) four stacked full-height green background layers drop down from the top like curtains (scaleY: 0 → 1, transform-origin: top) one after another with a small stagger; (2) overlapping that, the dark nav panel wipes in from top to bottom via an animated clip-path polygon; and (3) the menu links — split into masked lines — slide up from below their masks in three staggered groups. Clicking the toggler again .reverse()s the exact same timeline to close (curtains retract, panel wipes up), and the hamburger bars morph into an X via CSS. It is 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);

Layout / HTML

nav                                  (fixed top bar, flex space-between, z-index 2)
  .nav-logo > a > img                (small square logo mark, top-left)
  button.nav-toggler                 (hamburger — click target)
    span                             (top bar)
    span                             (bottom bar)

.nav-content                         (absolute overlay, pointer-events:none, z-index 1)
  .nav-bg                            (stacked background layer 1)
  .nav-bg                            (stacked background layer 2)
  .nav-bg                            (stacked background layer 3)
  .nav-bg                            (stacked background layer 4)
  .nav-items                         (the dark clip-path panel holding the links)
    .nav-items-col                   (col 1, narrower)
      .nav-socials    → 6 × a        (social links)
      .nav-legal      → 4 × a        (small legal links)
    .nav-items-col                   (col 2, wider)
      .nav-primary-links   → 5 × a   (big primary links)
      .nav-secondary-links → 4 × a   (medium secondary links)

section.hero                         (fullscreen background image, sits under the menu)

Use neutral, fictional labels — no real brand names:

  • .nav-socials (6): Bluesky, Pinterest, YouTube, Instagram, LinkedIn, X
  • .nav-legal (4): Cookie Policy, Accessibility, Data Rights, Disclosures
  • .nav-primary-links (5): Home, Experiments, Latest Updates, Documentation, Community
  • .nav-secondary-links (4): Playground, Build Something, Activity Feed, Profile

Styling

Fonts (Google Fonts): Onest (variable 100–900, body/links) and Google Sans (variable, optical-size + italic axes — loaded but the visible UI is essentially all Onest). body { font-family: "Onest", sans-serif; background-color: #141414; }.

Global reset: * { margin:0; padding:0; box-sizing:border-box; }.

Color tokens (all greens on near-black):

  • Page background: #141414
  • .nav-toggler span bars + all link text: #fff
  • .nav-legal a text: #318b6f (muted green)
  • .nav-items panel fill: #084331 (deep green)
  • The four stacked .nav-bg layers, in DOM order 1→4:
  • nth-child(1): #57cea5 (light mint green)
  • nth-child(2): #063124 (near-black deep green)
  • nth-child(3): #0b5c43 (dark green)
  • nth-child(4): #21ba80 (medium emerald)

Type sizes (all links: text-decoration:none; color:#fff; display:block; letter-spacing:-2%; line-height:1.1; margin-bottom:0.5rem;):

  • .nav-socials a: 1.25rem
  • .nav-legal a: 0.9rem, color #318b6f
  • .nav-primary-links a: 3rem (the big display links)
  • .nav-secondary-links a: 1.5rem
  • .nav-logo img: 40px × 40px

Structural / load-bearing CSS:

  • .hero: position: relative; width: 100%; height: 100svh; fullscreen background image no-repeat 50% 50%, background-size: cover.
  • nav: position: fixed; top: 0; width: 100%; flex justify-content: space-between; align-items: center; padding: 1rem; z-index: 2;.
  • .nav-logo, .nav-toggler: padding: 1rem; cursor: pointer;.
  • .nav-toggler: background: none; border: none; flex column, justify-content: center; align-items: center; gap: 5px;. Each span: width: 40px; height: 2px; background: #fff; transition: all 0.4s ease; (two thin white bars, 5px apart).
  • Hamburger → X morph is pure CSS (a transition, not GSAP), driven by an .open class the JS toggles on .nav-toggler:

``css .nav-toggler.open span:first-child { transform: translateY(3.5px) rotate(45deg) scaleX(0.75); } .nav-toggler.open span:nth-child(2) { transform: translateY(-3.5px) rotate(-45deg) scaleX(0.75); } ``

  • .nav-content: position: absolute; top: 0; left: 0; width: 100%; pointer-events: none; z-index: 1; (never blocks the hero when closed).
  • .nav-bg: position: absolute; top: 0; left: 0; width: 100%; height: 100%; z-index: -1; transform: scaleY(0); transform-origin: top; will-change: transform; pointer-events: none; — the four layers are stacked on top of each other, each collapsed to zero height at the top edge (this is the closed state the timeline animates from).
  • .nav-items: flex row, gap: 2rem; padding: 8rem; background-color: #084331; clip-path: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%); will-change: clip-path; — the closed clip-path is a degenerate zero-height rectangle pinned at the top edge (all four points at y:0), so the panel is invisible until the timeline expands it.
  • .nav-items-col:nth-child(1): flex: 2; flex column, justify-content: space-between; gap: 2rem; (socials on top, legal on bottom).
  • .nav-items-col:nth-child(2): flex: 4; flex row, gap: 2rem; justify-content: space-between; (primary + secondary link groups side by side).
  • Masked-line initial state (set in CSS, matched by SplitText output): .nav-content a .line { position: relative; will-change: transform; transform: translateY(100%); } — every split line starts pushed down 100%, hidden below its line-mask.

GSAP effect (exhaustive)

1. State flags + the paused timeline
const navToggler = document.querySelector(".nav-toggler");
const navBgs = document.querySelectorAll(".nav-bg");   // NodeList of the 4 layers

let isMenuOpen = false;
let isAnimating = false;

const tl = gsap.timeline({
  paused: true,
  onComplete: () => { isAnimating = false; },
  onReverseComplete: () => {
    gsap.set(linkBlocks.join(", "), { y: "100%" });   // re-hide every line after close
    isAnimating = false;
  },
});

The timeline is built once, paused. isAnimating is a re-entrancy guard set true on every click and cleared by onComplete (open) / onReverseComplete (close). On reverse-complete the link lines are force-reset back to y: "100%" because the link reveal is a separate set of tweens (not part of tl) and would otherwise stay revealed.

2. Timeline contents (exact order + timing)

Tween A — background curtain stack (appended at time 0):

tl.to(navBgs, {
  scaleY: 1,
  duration: 0.75,
  stagger: 0.1,
  ease: "power3.inOut",
});

All four .nav-bg layers animate scaleY: 0 → 1 from transform-origin: top, so each drops down from the top edge like a curtain. stagger: 0.1 fires them 0.1s apart (layer 1 at 0, layer 2 at 0.1, layer 3 at 0.2, layer 4 at 0.3), each over 0.75s on power3.inOut. Because the four colors are stacked with the light mint (#57cea5) first in DOM and the emerald/dark greens after, the staggered drop reads as overlapping colored curtains settling into place. This tween's total span = 0.75 + 0.1×3 = 1.05s.

Tween B — panel clip-path wipe (position "-=0.6", i.e. 0.6s before the end of tween A → starts at ≈ 1.05 − 0.6 = 0.45s):

tl.to(".nav-items", {
  clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)",
  duration: 0.75,
  ease: "power3.inOut",
}, "-=0.6");

Animates the dark panel's clip-path from the degenerate top-pinned rectangle to the full rectangle, so the panel wipes open downward from the top edge over 0.75s on power3.inOut, overlapping the tail of the curtain drop. Timeline ends at ≈ 0.45 + 0.75 = 1.2s.

3. SplitText — split every menu anchor into masked lines

Created once at init (after the timeline object exists, since linkBlocks is referenced above):

const splitLinks = SplitText.create(".nav-items a", {
  type: "lines",
  mask: "lines",
  linesClass: "line",
});
  • Targets all anchors inside .nav-items (socials, legal, primary, secondary).
  • type: "lines" splits each link into line elements; mask: "lines" wraps each line in an overflow: hidden mask; linesClass: "line" tags each with .line, which the CSS pre-offsets to translateY(100%) (hidden below its mask).
4. The link reveal (separate tweens, NOT on the timeline)

Links are grouped into three selector blocks:

const linkBlocks = [
  ".nav-socials .line, .nav-legal .line",   // block 1: both small-link groups in col 1
  ".nav-primary-links .line",               // block 2: the big display links
  ".nav-secondary-links .line",             // block 3: the medium links
];

function animateLinksIn() {
  linkBlocks.forEach((selector) => {
    gsap.fromTo(
      selector,
      { y: "100%" },
      {
        y: "0%",
        duration: 0.75,
        stagger: 0.05,
        ease: "power3.out",
        delay: 0.85,
      },
    );
  });
}

Each block runs its own fromTo sliding its .line elements y: 100% → 0% over 0.75s on power3.out, with a 0.05s internal stagger and a shared 0.85s delay — so all three blocks begin revealing at ~0.85s after open is triggered (while the panel is still wiping in), each group's lines cascading up 0.05s apart from behind their masks.

5. Toggle wiring
navToggler.addEventListener("click", () => {
  if (isAnimating) return;
  isAnimating = true;

  navToggler.classList.toggle("open");   // CSS morphs the two bars into an X

  if (!isMenuOpen) {
    tl.play();          // curtains drop + panel wipes open
    animateLinksIn();   // fire the (delayed) link reveal in parallel
  } else {
    tl.reverse();       // same timeline backwards: panel wipes up, curtains retract
  }

  isMenuOpen = !isMenuOpen;
});
  • Open: tl.play() runs the timeline forward; animateLinksIn() is called at the same moment so the link reveal (delayed 0.85s) overlaps the panel wipe. Toggler gains .open → CSS morphs bars into an X.
  • Close: tl.reverse() plays the identical timeline backwards — panel clip-path collapses back to the top, curtains retract to scaleY: 0 — and onReverseComplete re-hides the link lines (y: "100%"). Toggler loses .open → X un-morphs to a hamburger.
  • The isAnimating guard ignores clicks mid-animation; it clears on onComplete (open finished) or onReverseComplete (close finished).

Assets / images

  • 1 hero image — role: *fullscreen background behind the whole menu*. A bright, high-key abstract render of flowing white marble: glossy sculpted waves and swirling ridges with soft grey/charcoal veining threading through them. Dominant colors are white and light grey with darker grey vein accents — a light, airy surface (note: much brighter than the dark UI it sits under). Displayed background-size: cover at 50% 50%. Landscape ~16:9 (e.g. 1456×816 or larger).
  • 1 logo mark — role: *nav logo, top-left corner*. A near-white / off-white geometric outline mark (e.g. a rounded loop / racetrack shape) on a transparent background, square ~1:1 (512×512 PNG). Rendered at 40×40px. It reads on the dark hero but would be near-invisible on a light background.

Behavior notes

  • Responsive (max-width: 1000px): .nav-content and .nav-items get height: 100svh;. .nav-items becomes flex-direction: column; justify-content: center; padding: 0 2rem;. The .nav-legal and .nav-secondary-links groups are hidden (display: none), and both .nav-items-cols switch to flex: none;.
  • Entirely click-driven — no scroll, no ScrollTrigger, no autoplay, no loops.
  • The overlay .nav-content is pointer-events: none, so the closed menu never blocks the hero.
  • Single source of truth for open/close is the one paused, reversible timeline; the link reveal is fired alongside it and reset on reverse-complete.