Fullscreen Overlay Menu — Rotating Background Doors + Masked Line Reveal
Goal
Build a fixed top navbar with a circular hamburger toggle that opens a fullscreen overlay menu. The signature effect: two full-height background halves, each pre-rotated 180° and scaled ×2 around their inner edge, rotate back to 0° so they sweep in like two closing blades/doors on a custom cubic-bezier ease, while the hamburger bars morph into an X at the same time; then two columns of menu links plus a footer reveal line-by-line from a mask with a staggered power3.out slide-up. It is a single paused GSAP timeline: clicking the toggle .play()s it to open, clicking again .reverse()s the exact same timeline to close.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase. No smooth-scroll library, no ScrollTrigger — the whole thing is click-driven. Import as:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import { CustomEase } from "gsap/CustomEase";
gsap.registerPlugin(SplitText, CustomEase);
Layout / HTML
nav (fixed top bar, 3 equal flex columns)
.nav-toggle
.nav-toggle-btn (circular 60×60 hamburger button — click target)
span.bar-1
span.bar-2
.nav-logo > a "Carbon Structure" (centered wordmark)
.nav-cta > a "Start Journey" (right pill button)
.menu (fixed fullscreen overlay, pointer-events:none until open)
.menu-bg
.menu-bg-left > .menu-bg-left-inner (left half, its inner is the animated blade)
.menu-bg-right > .menu-bg-right-inner (right half, its inner is the animated blade)
.menu-items (flex row)
.menu-items-col (col 1) → 5 × .menu-link > a (uppercase sans links)
.menu-items-col (col 2) → 5 × .menu-link > a (serif links)
.menu-footer (absolute bottom bar, space-between)
.menu-footer-col → 4 × a (legal links)
.menu-footer-col → p "© 2025 Carbon Structure"
section.hero > h1 "Dense Geometry" (fullscreen background image sits behind the menu)
Use neutral, fictional menu labels — no real brand names. Col 1 links (uppercase): Manifesto, Spatial Journeys, Material Archive, Visit Atelier, Rituals. Col 2 links (serif): Tactile Vault, Form Experiments, Carbon Network, Shadow Library, Collections. Footer legal links: Usage Terms, Data & Cookies, Privacy Policy, Accessibility. Wordmark and hero heading are neutral placeholders.
Styling
Fonts (Google Fonts): Instrument Sans (body/UI, variable 400–700, has italics) and Instrument Serif (display/serif accent). body { font-family: "Instrument Sans"; }.
Color tokens:
--base-100: #fff(all text is white)--base-200: #474437(left background blade — muted olive/brown)--base-300: #403d31(right background blade — slightly darker olive)
Global: * { margin:0; padding:0; box-sizing:border-box; }. a, p { text-decoration:none; color:var(--base-100); font-weight:450; line-height:1; }.
Type:
.hero h1: Instrument Serif,font-size: clamp(3rem, 10vw, 12rem), weight 500,line-height: 0.75,width: 50%, centered, color white..menu-items-col:nth-child(1) a: uppercase (Instrument Sans),font-size: clamp(1.5rem, 2.5vw, 4rem),line-height: 1.1..menu-items-col:nth-child(2) a: Instrument Serif,font-size: clamp(1.65rem, 2.75vw, 3rem),line-height: 1.2..menu-footer a, .menu-footer p: uppercase,font-size: 0.75rem.nav .nav-logo a:width: 8rem, centered, uppercase,font-size: 0.9rem, weight 500.nav .nav-cta a: Instrument Serif,padding: 0.75rem 1.25rem,border: 1px solid rgba(255,255,255,0.15),border-radius: 5rem(pill).
Structural / load-bearing CSS:
.hero:position: relative; width: 100%; height: 100svh;fullscreen background imageno-repeat 50% 50% / cover, flex-centered,overflow: hidden.nav:position: fixed; width: 100%; padding: 2rem;flex,align-items: center,z-index: 2.nav > div { flex: 1; }..nav-logocenters its child;.nav-ctaright-aligns its child..nav-toggle-btn:width: 60px; height: 60px; padding: 20px;flex column,justify-content: center; gap: 5px;border: 1px solid rgba(255,255,255,0.15); border-radius: 10rem;cursor: pointer(a circular ring). Eachspan(.bar-1,.bar-2):width: 100%; height: 1.25px; background: var(--base-100); will-change: transform;(two thin white bars ~5px apart inside a 20px-wide content box)..menu:position: fixed; inset: 0; width: 100%; height: 100svh; pointer-events: none; overflow: hidden; z-index: 1;..menu.active { pointer-events: all; }(JS togglesactive)..menu-bg:position: absolute; inset: 0; width:100%; height:100%; pointer-events:none;..menu-bg-left,.menu-bg-right:position: absolute; width: 50%; height: 100%; overflow: hidden;— left dockedleft:0, right dockedright:0. Theoverflow:hiddenis what clips the oversized rotating inner blade..menu-bg-left-inner,.menu-bg-right-inner:position: absolute; width: 100%; height: 100%; will-change: transform;..menu-bg-left-inner:background-color: var(--base-200); transform-origin: 100% 50%; transform: rotate(180deg) scale(2, 2);.menu-bg-right-inner:background-color: var(--base-300); transform-origin: 0% 50%; transform: rotate(-180deg) scale(2, 2);- i.e. each half's inner panel is doubled in size and flipped 180° about the inner (center-facing) edge, so at rest it is swung out of frame; animating rotation to 0 swings it back to cover the half.
.menu-items:position: absolute; inset: 0; width:100%; height:100%;flex row..menu-items-col:flex: 1;flex column,justify-content: center; align-items: center; gap: 2rem;..menu-footer:position: absolute; bottom: 0; left: 0; width: 100%; padding: 2rem;flexspace-between,align-items: flex-end..menu-footer-col { display:flex; gap:2rem; }.- Masked-line initial state (set in CSS, matched by SplitText output):
.menu a .line, .menu p .line { position: relative; transform: translateY(110%); will-change: transform; }— every split line starts pushed down 110%, hidden behind its line-mask.
GSAP effect (exhaustive)
0. CustomEase
Register one custom ease before building the timeline:
CustomEase.create("hop", "0.85, 0, 0.15, 1");
This "hop" ease (a symmetric slow-in/slow-out, ~cubic-bezier(0.85, 0, 0.15, 1)) drives the background blades and the hamburger morph.
1. SplitText — split every menu text into masked lines
Run once at init, before the timeline:
SplitText.create(".menu a, .menu p", {
type: "lines",
mask: "lines",
linesClass: "line",
});
- Selector targets all anchors and the copyright
<p>inside.menu(both link columns + all footer text). type: "lines"splits into line elements;mask: "lines"wraps each line in an overflow-hidden mask so the line can be hidden below it;linesClass: "line"gives each line the.lineclass that the CSS pre-offsets totranslateY(110%).
2. The single paused timeline
const tl = gsap.timeline({ paused: true });
Add these tweens (positions noted — this is the exact order and timing):
At absolute time 0 (all four fire together, ease: "hop", duration: 1):
.nav-toggle-btn .bar-1→{ y: 3.25, rotation: 45, scaleX: 0.75 }— top bar drops 3.25px, rotates +45°, shrinks to 75% width..nav-toggle-btn .bar-2→{ y: -3.25, rotation: -45, scaleX: 0.75 }— bottom bar rises 3.25px, rotates −45°, shrinks to 75%. Together the two bars form an X..menu .menu-bg-left-inner→{ rotate: 0 }— swings the left blade from 180° back to 0° (sweeping in from the center edge to cover the left half)..menu .menu-bg-right-inner→{ rotate: 0 }— swings the right blade from −180° back to 0° (covering the right half).
At absolute time 0.6 (position param passed as the string "0.6"):
.menu-items-col:nth-child(1) .line→{ y: 0 },duration: 0.75,ease: "power3.out",stagger: 0.1— column-1 link lines rise fromtranslateY(110%)up into view, one after another 0.1s apart.
At the same start as the previous tween (position "<", i.e. also 0.6):
.menu-items-col:nth-child(2) .line→{ y: 0 },duration: 0.75,ease: "power3.out",stagger: 0.1..menu-footer .line→{ y: 0 },duration: 0.75,ease: "power3.out",stagger: 0.1.
So the two background blades + hamburger X animate over 0→1s on the "hop" ease, and starting at 0.6s the two link columns and the footer all reveal their lines simultaneously (each column staggered internally 0.1s) with power3.out. Total timeline ≈ 1.35s.
3. Toggle wiring
const menu = document.querySelector(".menu");
const menuToggle = document.querySelector(".nav-toggle-btn");
let isMenuOpen = false;
menuToggle.addEventListener("click", () => {
if (isMenuOpen) {
tl.reverse();
menu.classList.remove("active");
} else {
tl.play();
menu.classList.add("active");
}
isMenuOpen = !isMenuOpen;
});
- Open:
tl.play()runs the timeline forward and.menugetsactive(enables pointer events). - Close:
tl.reverse()plays the same timeline backwards — lines drop back into their masks, blades swing back out to ±180°, the X un-morphs into a hamburger — andactiveis removed. - No re-entrancy guard; GSAP handles mid-flight reversal naturally.
Assets / images
1 image, role = *fullscreen hero background* sitting behind the whole menu. Square (1:1) source. Subject: a single centered abstract 3D render of a soft, organically-twisted torus/ring (a doughnut-shaped loop with gentle wavy, molten undulations) resting on a flat surface, lit from above. Colors are a cool, near-monochrome palette — a brushed/satin silver-metallic ring in soft light and mid grays against a plain medium-gray studio background, with subtle darker shadow beneath. Clean, minimal, low-contrast and neutral, so the white nav/menu text and the olive blade overlay read clearly on top of it. Displayed background-size: cover at 50% 50%.
Behavior notes
- Responsive (
max-width: 1000px):navbecomesflex-direction: row-reverse; justify-content: space-between;(toggle right, wordmark left);.nav-ctais hidden. The right background half is hidden and.menu-bg-leftgrows towidth: 100%(single blade fills the screen)..menu-itemsbecomespadding: 6rem 2rem; flex-direction: column-reverse; gap: 4rem;, columns switch toalign-items: flex-start; gap: 0.5rem, and the footer columns stack (flex-direction: column; gap: 0). - Menu overlay is
pointer-events: noneuntil opened (.active), so it never blocks the hero when closed. - All motion is click-driven off one paused, reversible timeline — no scroll, no autoplay, no loops.