Fullscreen Menu with Staggered Links

A burger morphs into a cross while a full-screen curtain drops via clip-path; five giant serif links rise from masks with a stagger, a contact column fades in and a background index number follows the hovered link. Close simply reverses the timeline.

Loading

AI prompt

Recreate this effect with any AI tool

Paste into Claude, Cursor, v0, Lovable or Bolt. 1432 words of exact values — rewritten live as you tune the Playground.

prompt.md · Fullscreen Menu with Staggered Links
### EFFECT: Fullscreen Menu with Staggered Links
**What it does (visual):** A dark page with a fixed header: a small serif wordmark ("Atelier/Nord", the slash at 45% opacity) on the left and a 44px burger (two 22px hairlines, 8px apart) on the right. The hero copy drifts up on load. When the burger is pressed, the two bars slide together and rotate ±45° into a cross (0.5s) while, at the same moment, a light curtain (the inverse of the page colour) drops from the top edge to fill the whole viewport in 0.9s with a `power4.inOut` curve — revealed by `clip-path: inset()`, so the edge is a crisp horizontal line travelling downward, not a fade. 0.45s before the curtain lands, five giant light-serif words (Studio, Work, Services, Journal, Contact) rise out of invisible horizontal masks one after another, 0.07s apart, each decelerating hard into place (1s). A right-hand column of small mono labels and contact/social links fades up 12px, 40ms apart. A huge, 7%-opacity index number ("01") sits in the bottom-right corner behind everything; hovering a link makes it flick up and out and the new number ("03") rise in. Hovered links turn #d4ff3a and italic. Pressing the cross, Escape or a link plays the whole sequence backwards — words sink into their masks, the curtain lifts, the cross unfolds into a burger. The page cannot scroll while the menu is open. In the demo the menu opens by itself 0.7s after load so the sequence is visible without a click.

**Tech stack:**
- GSAP 3.13+ core (no plugins).
- CDN: `<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>`
- npm: `npm i gsap` → `import gsap from "gsap";`
- `clip-path: inset()` is animated directly by GSAP (string interpolation).

**HTML structure:**
```html
<div class="fsm">
  <header class="fsm__header">
    <a class="fsm__logo" href="#top">Atelier<span class="fsm__logo-sep">/</span>Nord</a>
    <button class="fsm__burger" type="button" aria-expanded="false" aria-controls="fsm-menu" aria-label="Open menu">
      <span class="fsm__bar"></span><span class="fsm__bar"></span>
    </button>
  </header>
  <nav class="fsm__overlay" id="fsm-menu" aria-label="Main menu" aria-hidden="true">
    <span class="fsm__bgnum" aria-hidden="true">01</span>
    <ul class="fsm__links">
      <li class="fsm__item"><a class="fsm__link" href="#studio" data-index="01"><span class="fsm__mask"><span class="fsm__text">Studio</span></span></a></li>
      <!-- Work 02, Services 03, Journal 04, Contact 05 -->
    </ul>
    <div class="fsm__aside">
      <div class="fsm__group"><p class="fsm__label">New business</p><a class="fsm__small" href="#">hello@ateliernord.studio</a><a class="fsm__small" href="#">+47 22 00 10 10</a></div>
      <div class="fsm__group"><p class="fsm__label">Elsewhere</p><a class="fsm__small" href="#">Instagram</a><a class="fsm__small" href="#">Are.na</a><a class="fsm__small" href="#">LinkedIn</a></div>
      <div class="fsm__group"><p class="fsm__label">Studios</p><p class="fsm__small">Oslo · Copenhagen</p></div>
    </div>
  </nav>
  <main class="fsm__page" id="top"> …hero + a tall filler section so there is scroll to lock… </main>
</div>
```

**CSS requirements:**
- `.fsm__header`: `position: fixed; inset: 0 0 auto 0; z-index: 60; display: flex; justify-content: space-between; align-items: center; padding: clamp(1rem, 2.5vw, 1.75rem) clamp(1rem, 4vw, 3rem); mix-blend-mode: difference; color: #fff;` — difference blending keeps logo + cross legible on both the dark page and the light curtain. The header MUST be above the overlay (z 60 > 50).
- `.fsm__burger`: `position: relative; width: 44px; height: 44px;` `.fsm__bar`: `position: absolute; left: 11px; width: 22px; height: 1.5px; background: #fff; will-change: transform;` first bar `top: calc(50% - 4.75px)`, second `top: calc(50% + 3.25px)` (8px apart → each travels 4px to meet).
- `.fsm__overlay`: `position: fixed; inset: 0; z-index: 50; visibility: hidden; overflow: hidden; background: var(--text); color: var(--bg); clip-path: inset(0 0 100% 0); will-change: clip-path; display: grid; grid-template-columns: minmax(0, 7fr) minmax(0, 4fr); align-items: end; gap: 3rem; padding: clamp(5rem, 14vh, 8rem) clamp(1rem, 4vw, 3rem) clamp(2rem, 6vh, 4rem);`
- `.fsm__bgnum`: `position: absolute; right: -0.05em; bottom: -0.18em; font-family: var(--font-display); font-weight: 300; font-size: clamp(14rem, 42vw, 40rem); line-height: 1; letter-spacing: -0.06em; color: var(--bg); opacity: .07; pointer-events: none;`
- `.fsm__mask { display: block; overflow: hidden; padding: 0 .08em; margin: 0 -.08em; }` (padding stops italic overhangs clipping).
- `.fsm__text`: `display: block; font-family: var(--font-display); font-weight: 300; font-size: clamp(2.5rem, min(9vw, 11.5vh), 7.5rem); line-height: .95; letter-spacing: -0.04em; will-change: transform; transition: color .3s;` — `.fsm__link:hover .fsm__text, .fsm__link:focus-visible .fsm__text { color: var(--accent); font-style: italic; }`
- `.fsm__aside { display: grid; gap: 1.75rem; }` `.fsm__label`: mono 10.5px uppercase, `letter-spacing: .14em; opacity: .55`. `.fsm__small`: `display: block; font-size: .95rem; line-height: 1.5;` hover → `var(--accent)`.
- Accent / hover colour: `--accent: #d4ff3a` (link hover, small-link hover, focus rings).
- Hero: `.fsm__title` display serif 300, `font-size: clamp(2.75rem, 7.5vw, 7.5rem); letter-spacing: -0.04em; line-height: .92;` `.fsm__copy` muted, `max-width: 52ch`. A `.fsm__filler` section of `min-height: 60vh` follows so scroll-lock is observable.

**Animation spec (exact values):**
1. Intro: `gsap.from('.fsm__hero > *', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.1 })`.
2. One paused master timeline, `defaults: { ease: 'power4.inOut' }`, `onReverseComplete: () => gsap.set(overlay, { visibility: 'hidden' })`:
   ```js
   tl.set(overlay, { visibility: 'visible' }, 0)
     .to(bars[0], { y: 4,  rotate: 45,  duration: 0.5 }, 0)
     .to(bars[1], { y: -4, rotate: -45, duration: 0.5 }, 0)
     .fromTo(overlay, { clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0% 0)', duration: 0.9 }, 0)
     .fromTo(texts, { yPercent: 110 }, { yPercent: 0, duration: 1, ease: 'power4.out', stagger: 0.07 }, '-=0.45')
     .fromTo(asideEls, { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: 'power3.out', stagger: 0.04 }, '<0.1');
   ```
   - Burger: top bar `y 0 → 4, rotate 0 → 45°`; bottom bar `y 0 → -4, rotate 0 → -45°`; 0.5s `power4.inOut`, at 0.
   - Curtain: `.fsm__overlay` `clip-path inset(0 0 100% 0) → inset(0 0 0% 0)`, 0.9s `power4.inOut`, at 0.
   - Links: every `.fsm__text` `yPercent 110 → 0`, 1s `power4.out`, stagger 0.07, starting 0.45s before the curtain ends (t = 0.9 − 0.45).
   - Aside: labels + small links `y 12 → 0, opacity 0 → 1`, 0.6s `power3.out`, stagger 0.04, starting 0.1s after the first link starts.
3. `open()`: set `aria-expanded="true"`, `aria-label="Close menu"`, `overlay aria-hidden="false"`, `document.documentElement.style.overflow = 'hidden'`, `tl.play()`, focus the first link, and `gsap.fromTo(bgnum, { yPercent: 12, opacity: 0 }, { yPercent: 0, opacity: 0.07, duration: 1.1, ease: 'power3.out', delay: 0.55, overwrite: 'auto' })` (kept outside the master timeline so hover swaps never fight it).
4. `close()`: reset the attributes, clear the overflow lock, `tl.timeScale(1.5).reverse()` (the close is the exact mirror, played 1.5× faster — `open()` resets `timeScale(1)` before `play()`), focus the burger.
5. Index swap on link `mouseenter`/`focus` (`setIndex(n)`, skipped if `n === current`):
   `gsap.timeline().to(bgnum, { yPercent: -12, opacity: 0, duration: 0.18, ease: 'power2.in', overwrite: 'auto' }).add(() => bgnum.textContent = n).fromTo(bgnum, { yPercent: 12 }, { yPercent: 0, opacity: 0.07, duration: 0.45, ease: 'power3.out' })`.
6. Demo auto-open: `setTimeout(open, 700)` unless reduced motion; cleared on first manual open.

**Interaction:** Burger click toggles open/close. Escape closes. Clicking any menu link closes (demo links are `#` anchors with `preventDefault`). While open, Tab/Shift+Tab cycle only through the burger and the overlay's links (focus trap). Hovering a big link: #d4ff3a + italic, and the background index swaps (0.18s out, 0.45s in).

**Responsive behavior:** Under 768px the overlay becomes one column (`grid-template-columns: 1fr; align-content: space-between; overflow-y: auto; padding-top: 5.5rem`), links use `font-size: clamp(2.5rem, 13vw, 4rem)`, the aside becomes a 2-column grid of small groups and the index number is `60vw`. The timeline is identical on touch; no `gsap.matchMedia()` branch is needed because every value is relative (`yPercent`, `clip-path` percentages).

**Accessibility:** `aria-expanded` + `aria-controls` on the burger, `aria-hidden` toggled on the `<nav>`, focus moved to the first link on open and back to the burger on close, Tab trapped while open, Escape closes, `<html>` scroll locked while open. With `prefers-reduced-motion: reduce`: no intro, no auto-open, `open()` does `tl.progress(1)` and `close()` does `tl.progress(0)` (instant), index text swaps without motion, and hover transitions are disabled.

**Performance rules:** Only `transform`, `opacity` and `clip-path` are animated (all compositor-friendly in current browsers). `will-change: transform` on the bars and link texts, `will-change: clip-path` on the overlay. One timeline is built once and reused — no tweens are created per open. Everything is created inside `gsap.context()`; the context's cleanup removes the keydown listener and the scroll lock, and `ctx.revert()` runs on unmount.

**Customization knobs:**
- Curtain duration (0.9) and ease (`power4.inOut`); try `expo.inOut`.
- Link `stagger` (0.07) and `duration` (1); `yPercent` (110).
- Curtain overlap (`'-=0.45'`) — larger = links start earlier.
- Burger travel (`±4px`) must equal half the bar gap.
- Overlay colours: `background: var(--text); color: var(--bg)` → swap for `var(--accent)` for a #d4ff3a curtain.
- Hover / accent colour `--accent` (#d4ff3a).
- Index opacity (0.07).

**Acceptance criteria:**
- [ ] Closed state: only header + page visible; overlay is `visibility: hidden` and not focusable.
- [ ] Opening: bars form a cross in 0.5s, curtain fully covers the viewport at 0.9s, links start rising 0.45s before that with 0.07s between them, aside fades in after.
- [ ] Closing (cross, Escape or link) is the exact reverse; interrupting mid-way reverses from the current position without a jump.
- [ ] Page cannot scroll while open; focus is trapped; focus returns to the burger on close.
- [ ] Hovering links updates the background index with the flick animation.
- [ ] Reduced motion: instant open/close; no console errors; works at 375px.

Source

Full source code

<div class="fsm">
  <header class="fsm__header">
    <a class="fsm__logo" href="#top">Atelier<span class="fsm__logo-sep">/</span>Nord</a>
    <button class="fsm__burger" type="button" aria-expanded="false" aria-controls="fsm-menu" aria-label="Open menu">
      <span class="fsm__bar"></span><span class="fsm__bar"></span>
    </button>
  </header>

  <nav class="fsm__overlay" id="fsm-menu" aria-label="Main menu" aria-hidden="true">
    <span class="fsm__bgnum" aria-hidden="true">01</span>
    <ul class="fsm__links">
      <li class="fsm__item"><a class="fsm__link" href="#studio" data-index="01"><span class="fsm__mask"><span class="fsm__text">Studio</span></span></a></li>
      <li class="fsm__item"><a class="fsm__link" href="#work" data-index="02"><span class="fsm__mask"><span class="fsm__text">Work</span></span></a></li>
      <li class="fsm__item"><a class="fsm__link" href="#services" data-index="03"><span class="fsm__mask"><span class="fsm__text">Services</span></span></a></li>
      <li class="fsm__item"><a class="fsm__link" href="#journal" data-index="04"><span class="fsm__mask"><span class="fsm__text">Journal</span></span></a></li>
      <li class="fsm__item"><a class="fsm__link" href="#contact" data-index="05"><span class="fsm__mask"><span class="fsm__text">Contact</span></span></a></li>
    </ul>
    <div class="fsm__aside">
      <div class="fsm__group">
        <p class="fsm__label">New business</p>
        <a class="fsm__small" href="#contact">hello@ateliernord.studio</a>
        <a class="fsm__small" href="#contact">+47 22 00 10 10</a>
      </div>
      <div class="fsm__group">
        <p class="fsm__label">Elsewhere</p>
        <a class="fsm__small" href="#instagram">Instagram</a>
        <a class="fsm__small" href="#arena">Are.na</a>
        <a class="fsm__small" href="#linkedin">LinkedIn</a>
      </div>
      <div class="fsm__group">
        <p class="fsm__label">Studios</p>
        <p class="fsm__small">Oslo · Copenhagen</p>
        <p class="fsm__small">Open Mon – Fri, 09–18 CET</p>
      </div>
    </div>
  </nav>

  <main class="fsm__page" id="top">
    <section class="fsm__hero">
      <p class="fsm__eyebrow">Navigation · Fullscreen menu</p>
      <h1 class="fsm__title">A menu that takes the whole room</h1>
      <p class="fsm__copy">Press the burger in the corner. A curtain drops, five words rise out of it, and the page underneath stops scrolling until you leave. The demo opens once by itself so you can see the sequence; close it with the cross or <kbd>Esc</kbd>.</p>
    </section>
    <section class="fsm__filler">
      <p class="fsm__filler-text">Scroll is locked while the menu is open — this section exists so there is something to lock.</p>
    </section>
  </main>
</div>