All components

Jam Area Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

Full-screen overlay menu that wipes open with a clip-path reveal when the Menu toggle is clicked, staggering in oversized SplitText links with per-character hover swaps. A lerped accent highlighter bar tracks the hovered link while the whole link strip pans with the mouse.

How it's built

Categorymenu
Techgsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitysection
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

overlay menu lenis hover clip-path split-text

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

Full-Screen Overlay Menu with Clip-Path Wipe & Lerped Highlighter

Goal

Build a fixed top navbar whose Menu toggle opens a full-screen dark overlay that wipes open from the bottom edge upward via an animated clip-path polygon. As it opens, two columns of meta text fade/slide in, a small centered image scales up, and a row of five oversized (Anton, 10rem) menu links stagger up into view from a mask. On desktop, each link does a per-character vertical swap on hover (SplitText), a lime highlighter bar slides + resizes to track the hovered link using a lerp loop, and the entire link strip pans horizontally with the mouse X position (also lerped). It is a click-to-open/close menu, not scroll-driven.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm), the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scroll. Import as:

import { gsap } from "gsap";
import { ScrollTrigger, SplitText } from "gsap/all";
import Lenis from "lenis";

Register gsap.registerPlugin(ScrollTrigger, SplitText) inside DOMContentLoaded. (ScrollTrigger is only wired to Lenis for smooth scroll updates; there are no scroll-triggered animations in this component — everything is click/hover/mousemove driven.)

Layout / HTML

nav                       (fixed top bar)
  .nav-toggle > p "Menu"        (left — click target that opens/closes)
  .nav-item   > p "Archive"     (right — decorative)

.menu-overlay             (fixed full-viewport dark panel, clipped closed)
  .menu-content                 (two meta-text columns, absolutely centered-ish)
    .menu-col   → stacked <p> lines separated by <br> (studio name, address, city, edition, contact email, phone)
    .menu-col   → stacked <p> lines separated by <br> (social links, language, credits/ref) — right-aligned
  .menu-img                     (single small centered image)
    img
  .menu-links-wrapper           (bottom-left horizontal row)
    .menu-link × 5              (each links to "#")
      a
        span  (visible copy)    ← duplicate text
        span  (hover copy)      ← same text, stacked absolutely on top
    .link-highlighter           (the lime tracking bar)

.container
  section.hero > h1 "Shaping Ideas"   (full-viewport light hero behind the overlay)

The five link labels are: Index, Persona, Biography, Work, Journal. Each .menu-link a contains the same word twice in two <span>s (span 1 = visible copy, span 2 = hover copy stacked directly on top).

Use a neutral fictional studio identity for the meta text (e.g. "Jam Area", a street/city, an edition/volume number, a contact email, a phone number, social platforms, a language, credits/imprint, a reference code). No real brands.

Styling

Fonts (Google Fonts): Anton (display) and DM Sans (UI/body, variable, italic + optical size + weight axes).

Color tokens:

  • --dark: #1e1e1e
  • --light: #fefff8
  • --accent: #9dfc11 (lime green)
  • body { background: #000; }

Type:

  • h1: Anton, uppercase, font-size: 10rem, weight 500, letter-spacing: -0.1rem, line-height: 0.9. .hero h1 width 70%.
  • p, a: uppercase, font-size: 0.8rem, weight 600, line-height: 1, no underline, user-select: none.
  • .menu-link a: Anton, font-size: 10rem, weight 500, letter-spacing: -0.2rem, display: inline-block, overflow: hidden, color --light.
  • img { width:100%; height:100%; object-fit: cover; }

Key positioning / structural CSS (these are load-bearing for the effect):

  • nav: position: fixed; top:0; left:0; width:100vw; padding:1rem; flex space-between, color --light, mix-blend-mode: difference, z-index:2. nav p { padding:1rem; cursor:pointer; }
  • .menu-overlay: position: fixed; inset:0 auto auto 0; width:100vw; height:100svh; overflow:hidden; background --dark, color --light, z-index:1, will-change: clip-path. Initial closed state: clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%) (a degenerate rectangle collapsed onto the bottom edge — overlay invisible).
  • .menu-content: position:absolute; top:45%; transform:translateY(-50%); width:100%; padding:2rem; flex space-between / align-items:center. .menu-col:nth-child(2){ text-align:right; }
  • .menu-img: position:absolute; top:45%; left:50%; transform:translate(-50%,-50%); width:150px;
  • .menu-links-wrapper: position:absolute; left:0; bottom:0; width:max-content; padding:2rem; flex, gap:2rem, will-change: transform.
  • .menu-link: position:relative; overflow:hidden; will-change:transform. (Overflow toggles to visible once open so the highlighter/hover chars aren't clipped — see GSAP section.)
  • .menu-link a span:nth-child(2): position:absolute; top:0; left:0; (hover copy stacked on the visible copy).
  • .link-highlighter: position:absolute; bottom:0; left:0; width:400px; height:0.75rem; background --accent, will-change: transform, width. (JS overwrites width to the first link's measured width.)
  • .char (created by SplitText): position:relative; display:inline-block; will-change:transform;
  • section: width:100vw; height:100svh; flex-centered, background --light, color --dark, padding:2rem.
  • .container: position:relative; z-index:0; will-change:transform, opacity;

GSAP effect (exhaustive)

0. Smooth scroll bootstrap

const lenis = new Lenis();lenis.on("scroll", ScrollTrigger.update); drive it from the GSAP ticker: gsap.ticker.add((time)=> lenis.raf(time*1000)); gsap.ticker.lagSmoothing(0).

1. SplitText per link + initial states

For each .menu-link a, grab its two <span>s. For each span run new SplitText(span, { type: "chars" }) and add class .char to every resulting char. For the second span only (index 1, the hover copy), gsap.set(split.chars, { y: "110%" }) so its characters sit one line-height below (hidden by the a's overflow:hidden).

Then set the closed/pre-open states:

  • gsap.set(menuContent, { y: "50%", opacity: 0.25 })
  • gsap.set(menuImage, { scale: 0.5, opacity: 0.25 })
  • gsap.set(menuLinks, { y: "150%" })menuLinks = all .menu-link a
  • gsap.set(linkHighlighter, { y: "150%" })
2. Measure highlighter to first link

Read the first link's first span offsetWidth → set linkHighlighter.style.width to that px and seed currentHighlighterWidth = targetHighlighterWidth = linkWidth. Compute initialX = firstLink.getBoundingClientRect().left - menuLinksWrapper.getBoundingClientRect().left → seed currentHighlighterX = targetHighlighterX = initialX.

3. Open sequence (click .nav-toggle, isMenuOpen === false)

Guard with isMenuAnimating (bail if already animating; set true at start). These are independent gsap.to tweens fired simultaneously, each with its own delay — not a single timeline. All eases are expo.out.

  • container{ y: "-40%", opacity: 0.25, duration: 1.25 } (pushes the hero back).
  • menuOverlay{ clipPath: "polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)", duration: 1.25 } — this grows the top two corners up to 0%, wiping the panel open from the bottom edge upward to full-screen. onComplete: gsap.set(container, { y: "40%" }), gsap.set(".menu-link", { overflow: "visible" }), then isMenuOpen = true; isMenuAnimating = false.
  • menuContent{ y: "0%", opacity: 1, duration: 1.5 }.
  • menuImage{ scale: 1, opacity: 1, duration: 1.5 }.
  • menuLinks{ y: "0%", duration: 1.25, stagger: 0.1, delay: 0.25 } — the five oversized links rise up out of the mask left-to-right.
  • linkHighlighter{ y: "0%", duration: 1, delay: 1 } — the lime bar drops in last.
4. Close sequence (click .nav-toggle, isMenuOpen === true)

Again independent simultaneous tweens, all expo.out, duration: 1.25 unless noted:

  • container{ y: "0%", opacity: 1 } (hero returns).
  • menuLinks{ y: "-200%" } (links shoot up out of frame).
  • menuContent{ y: "-100%", opacity: 0.25 }.
  • menuImage{ y: "-100%", opacity: 0.5 }.
  • menuOverlay{ clipPath: "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)" } — collapses the panel upward into the top edge. onComplete resets everything to the closed state: overlay clip-path back to the bottom-collapsed polygon polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%), menuLinks y:"150%", linkHighlighter y:"150%", menuContent {y:"50%", opacity:0.25}, menuImage {y:"0%", scale:0.5, opacity:0.25}, .menu-link { overflow: "hidden" }, menuLinksWrapper { x: 0 } and reset currentX = targetX = 0; then isMenuOpen = false; isMenuAnimating = false.
5. Per-character hover swap (desktop only, window.innerWidth >= 1000)

On each .menu-link mouseenter: get the two spans' .char sets.

  • visible copy chars → gsap.to({ y: "-110%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" })
  • hover copy chars → gsap.to({ y: "0%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" })

On mouseleave:

  • hover copy chars → { y: "110%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" }
  • visible copy chars → { y: "0%", stagger: 0.03, duration: 0.5, ease: "expo.inOut" }

Net effect: the word rolls upward, the visible copy exiting through the top while the duplicate copy rides in from below, character by character.

6. Mouse-driven horizontal pan of the link strip (desktop only)

Listen for mousemove on .menu-overlay. Compute:

  • maxMoveLeft = 0, maxMoveRight = viewportWidth - menuLinksWrapperWidth (negative-space room to slide the strip fully right).
  • sensitivityRange = viewportWidth * 0.5; startX = (viewportWidth - sensitivityRange)/2; endX = startX + sensitivityRange.
  • mousePercentage: 0 if mouseX <= startX, 1 if mouseX >= endX, else (mouseX - startX) / sensitivityRange.
  • targetX = maxMoveLeft + mousePercentage * (maxMoveRight - maxMoveLeft).

So only the central 50% of the screen width is active: sweeping the cursor across it pans the whole .menu-links-wrapper from its left-docked position to fully right-docked.

7. Highlighter target on hover (desktop only)

On each .menu-link mouseenter: targetHighlighterX = linkRect.left - menuWrapperRect.left and targetHighlighterWidth = (that link's first span).offsetWidth. On .menu-links-wrapper mouseleave: reset both targets back to the first link's position/width.

8. Lerp rAF loop (animate())

A requestAnimationFrame loop with lerpFactor = 0.05 interpolates each current toward its target:

currentX               += (targetX               - currentX)               * 0.05
currentHighlighterX    += (targetHighlighterX    - currentHighlighterX)    * 0.05
currentHighlighterWidth+= (targetHighlighterWidth- currentHighlighterWidth)* 0.05

Then each frame apply:

  • gsap.to(menuLinksWrapper, { x: currentX, duration: 0.3, ease: "power4.out" })
  • gsap.to(linkHighlighter, { x: currentHighlighterX, width: currentHighlighterWidth, duration: 0.3, ease: "power4.out" })

This double-smooths (lerp + short power4.out tween) so the strip pan and the bar's slide/resize feel fluid and trailing.

Assets / images

1 image, role = *small centered menu image*. A single portrait-oriented black-and-white editorial photograph displayed at ~150px wide (object-fit: cover), sitting dead-center of the open overlay between the two text columns. The real asset is a tightly-cropped monochrome studio headshot of a young person facing the camera, short hair, dark top, shot against a plain light-grey seamless backdrop — dominant tones are soft greys and near-blacks with bright highlights, no color. The high-contrast grayscale reads cleanly against the dark panel. Provide one image; aspect ratio ~3:4 portrait.

Behavior notes

  • Desktop-only interactions: the hover char-swap, mouse pan, and highlighter tracking all early-return when window.innerWidth < 1000.
  • Responsive (max-width: 1000px): hero h1 becomes width:100%; font-size:4rem; .menu-content top:25%; .menu-img and .link-highlighter are display:none; .menu-links-wrapper switches to flex-direction: column; gap: 0; .menu-link a shrinks to font-size:4rem; letter-spacing:-0.05rem (links stack vertically).
  • Re-entrancy guard: isMenuAnimating blocks toggling mid-animation; isMenuOpen tracks state. The open tween's onComplete flips container to y:"40%" (so the hero peeks from below while open) and unhides link overflow.
  • The animate() rAF loop runs continuously for the lifetime of the page.