All components

Audemars Piguet Overlay Menu

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

Open live demo ↗ Raw prompt (.md)

What it does

Fullscreen overlay menu opened from a hamburger toggle: two background halves pre-rotated 180deg with scale(2) sweep in like closing doors on a custom cubic-bezier ease while the bars morph into an X, then SplitText-masked menu links and footer lines slide up with staggered power3.out reveals. Clicking the toggle again reverses the same paused timeline to close.

How it's built

Categorymenu
Techgsap
GSAP pluginsSplitText, CustomEase
Complexitysection
Performance costmedium
Mobile-safeyes

overlay menu hamburger split-text custom-ease rotate-reveal masked-lines

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 — 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 image no-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-logo centers its child; .nav-cta right-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). Each span (.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 toggles active).
  • .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 docked left:0, right docked right:0. The overflow:hidden is 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; flex space-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 .line class that the CSS pre-offsets to translateY(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):

  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.
  2. .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.
  3. .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).
  4. .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"):

  1. .menu-items-col:nth-child(1) .line{ y: 0 }, duration: 0.75, ease: "power3.out", stagger: 0.1 — column-1 link lines rise from translateY(110%) up into view, one after another 0.1s apart.

At the same start as the previous tween (position "<", i.e. also 0.6):

  1. .menu-items-col:nth-child(2) .line{ y: 0 }, duration: 0.75, ease: "power3.out", stagger: 0.1.
  2. .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 .menu gets active (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 — and active is 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): nav becomes flex-direction: row-reverse; justify-content: space-between; (toggle right, wordmark left); .nav-cta is hidden. The right background half is hidden and .menu-bg-left grows to width: 100% (single blade fills the screen). .menu-items becomes padding: 6rem 2rem; flex-direction: column-reverse; gap: 4rem;, columns switch to align-items: flex-start; gap: 0.5rem, and the footer columns stack (flex-direction: column; gap: 0).
  • Menu overlay is pointer-events: none until 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.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/audemarspiguet-menu/hero.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--rhodium, --nuit, --door-left, --door-right, --muted, --brass, --ruby, --line. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: one script that runs once, at the moment the module is evaluated, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the toggle looks right on the first click and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Run this component's setup twice against the same DOM and you get two click listeners stacked on the same .nav-toggle-btn, each closed over its own isMenuOpen and its own paused tl; one click now fires both, so two timelines write into the same four inline-transform targets (.bar-1, .bar-2, both .menu-bg-*-inner panels) on the same frame, and .menu's active class gets added and removed by two flags free to drift out of sync after a couple of clicks — one convinced the menu is open, the other convinced it's closed. Layered on top of that, a second, unreverted SplitText.create pass wraps a fresh .line around text that already has one, so the three staggered reveal tweens end up targeting whichever nesting depth their selector happens to match, not necessarily the depth the CSS mask actually clips. None of this reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — Nothing in this script waits for anything: gsap.registerPlugin(SplitText, CustomEase), CustomEase.create("hop", …), the document.querySelector calls for .menu and .nav-toggle-btn, the SplitText.create pass, the tl timeline with its seven tweens, and the click listener on menuToggle all run at module-evaluation time. In a React app that moment is import time — before this component has rendered anything — so document.querySelector(".nav-toggle-btn") resolves to null and menuToggle.addEventListener(...) throws immediately. That's a harder failure than a missed DOMContentLoaded: a crash at import, not a silent no-op. Move everything from the SplitText.create call through the click listener into a useEffect with an empty dependency array. gsap.registerPlugin and CustomEase.create("hop", …) are the two exceptions — neither touches the DOM, and both stay at module scope; recreating the "hop" ease on every mount is harmless but pointless.

*(2) Element lookups* — menu and menuToggle are two separate document.querySelector calls, and SplitText.create runs its own selector (.menu a, .menu p) that a gsap.context scope does not reach — context scoping only rewrites selector strings passed as tween or timeline targets, not the query SplitText runs internally. Give the component a root ref on the wrapper around nav and .menu, resolve menu and menuToggle from it, and hand SplitText.create an actual node list — rootRef.current.querySelectorAll(".menu a, .menu p") — instead of the bare selector string. The seven tween targets (.nav-logo, .nav-toggle-btn .bar-1/.bar-2, .menu-bg-left-inner/.menu-bg-right-inner, both .menu-items-col … .line groups, .menu-footer .line) can stay as plain strings, since those do get scoped once the context's second argument is the root ref. The distinction matters here specifically: during the StrictMode remount two copies of .nav-toggle-btn exist for an instant, and an unscoped SplitText.create selector has no way to tell which copy's anchors belong to the subtree that's staying.

*(3) Cleanup* — Wrap the split and the timeline construction in one gsap.context scoped to the root ref, keep the split instance and a named click handler, and unwind both explicitly:

useEffect(() => {
  const ctx = gsap.context(() => {
    const split = SplitText.create(
      rootRef.current.querySelectorAll(".menu a, .menu p"),
      { type: "lines", mask: "lines", linesClass: "line" }
    );
    const tl = gsap.timeline({ paused: true });
    // the seven tweens exactly as built above, addressed off `tl`
    return () => split.revert();
  }, rootRef);

  let isMenuOpen = false;
  const handleToggle = () => {
    /* the branch above: tl.play()/tl.reverse() and the .active class toggle */
  };
  const toggleBtn = rootRef.current.querySelector(".nav-toggle-btn");
  toggleBtn.addEventListener("click", handleToggle);

  return () => {
    toggleBtn.removeEventListener("click", handleToggle);
    ctx.revert();
  };
}, []);

ctx.revert() kills tl and every inline transform it wrote onto the bars, the two background panels and the split lines, and — because the factory function returns split.revert() — reverts the split as part of that same call, after the tweens referencing those lines are already gone. Two things the context does not reach on its own: the click listener, because it isn't a GSAP object — the original script hands addEventListener an inline arrow with nothing to pass to removeEventListener later, so give the handler a name when you port it and remove it in the same cleanup; and isMenuOpen itself, which should stay a plain variable local to the effect rather than become useState — it never drives a render, .active and the timeline already own everything visible, and a fresh isMenuOpen reset on every effect run is exactly the behavior you want on remount.

One risk the current script doesn't carry but a port should close: .menu a, .menu p spans both link columns, and the two use different faces — column one is Instrument Sans (uppercase), column two Instrument Serif — plus the Instrument Sans footer copy. mask: "lines" measures line boxes against whichever face is active the instant SplitText.create runs; if either one is still loading, the lines get masked against the fallback face's metrics, and the break points — so what each staggered tween actually reveals — shift once the real font swaps in. If your app's font-loading setup doesn't already guarantee both faces are in before this mounts, gate the split behind document.fonts.ready: keep the effect itself synchronous, start the split from inside a .then(), and check the same cancellation flag the cleanup sets before touching rootRef there, since that promise can resolve after a StrictMode unmount has already reverted the context it would otherwise add to.