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.
- Navigation
- gsap
- intermediate
- light perf
- mobile: full
- reduced-motion ✓
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.
### 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. Add this effect to my existing site/section: **Fullscreen Menu with Staggered Links**. Keep my header, logo and nav links; give the burger two `.fsm__bar` spans and `aria-expanded`/`aria-controls`, and move the nav into a fixed `.fsm__overlay` (`clip-path: inset(0 0 100% 0); visibility: hidden; z-index` below the header) with each link's text wrapped in an `overflow: hidden` `.fsm__mask` → `.fsm__text`. Build one paused GSAP timeline: bars `y ±4, rotate ±45°` (0.5s `power4.inOut`), overlay `clip-path → inset(0 0 0% 0)` (0.9s `power4.inOut`), link texts `yPercent 110 → 0` (1s `power4.out`, stagger 0.07, position `'-=0.45'`), secondary links `y 12 → 0, opacity 0 → 1` (0.6s `power3.out`, stagger 0.04, `'<0.1'`). Open = `tl.timeScale(1).play()`, close = `tl.timeScale(1.5).reverse()`; toggle aria attributes, lock `<html>` overflow, trap Tab, close on Escape, return focus to the burger. Hovered links turn `#d4ff3a` and italic. Respect `prefers-reduced-motion` with `tl.progress(1|0)`. Wrap in `gsap.context()` and revert on unmount. Do not change my fonts or colours outside the overlay. ### EFFECT (React / Next.js): Fullscreen Menu with Staggered Links
Build **Fullscreen Menu with Staggered Links** as a self-contained React component for a Next.js App Router project (also valid in Vite + React). Follow the specification below exactly; the vanilla reference implementation at the end is the source of truth for values and structure.
**Setup**
- `npm i gsap @gsap/react`
- File: `components/FullscreenMenuWithStaggeredLinks.tsx`, first line `"use client";` (GSAP touches the DOM).
- Imports:
```tsx
"use client";
import { useRef } from "react";
import gsap from "gsap";
import { useGSAP } from "@gsap/react";
gsap.registerPlugin(useGSAP);
export default function FullscreenMenuWithStaggeredLinks() {
const scope = useRef<HTMLDivElement>(null);
useGSAP(() => {
// 1. query elements inside `scope.current` (never document-wide)
// 2. wait for document.fonts.ready before any SplitText
// 3. build the timeline / ScrollTriggers exactly as in the specification
// 4. return nothing — useGSAP reverts everything automatically on unmount
}, { scope });
return (
<div ref={scope} className="fsm">
{/* markup from the HTML structure section, class → className */}
</div>
);
}
```
**React-specific rules**
- Everything GSAP-related lives inside `useGSAP(() => {...}, { scope })`; it handles cleanup (`ctx.revert()`) on unmount and in React Strict Mode double-invocation.
- Use refs or `gsap.utils.selector(scope)` instead of `document.querySelector`.
- Event listeners added inside useGSAP must use `contextSafe` (`const { contextSafe } = useGSAP({ scope })`) so their tweens are reverted too.
- ScrollTrigger: call `ScrollTrigger.refresh()` after images/fonts load; if the page uses Lenis, sync it once at the app root (`lenis.on('scroll', ScrollTrigger.update)`).
- Styles: copy the CSS requirements into a CSS Module (`FullscreenMenuWithStaggeredLinks.module.css`) or Tailwind; keep the class names so the spec maps 1:1.
- Respect `prefers-reduced-motion` with `gsap.matchMedia()` as in the spec.
---
### 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.
---
**Reference implementation (vanilla, converts 1:1 — same class names, same values):**
```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>
<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>
```
```css
.fsm { position: relative; }
.fsm__header { position: fixed; inset: 0 0 auto 0; z-index: 60; display: flex; align-items: center; justify-content: space-between; padding: clamp(1rem, 2.5vw, 1.75rem) clamp(1rem, 4vw, 3rem); mix-blend-mode: difference; color: #fff; }
.fsm__logo { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.fsm__logo-sep { opacity: .45; margin: 0 .1em; }
.fsm__burger { position: relative; width: 44px; height: 44px; margin-right: -10px; }
.fsm__burger:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; border-radius: 50%; }
.fsm__bar { position: absolute; left: 11px; width: 22px; height: 1.5px; background: #fff; border-radius: 1px; will-change: transform; }
.fsm__bar:first-child { top: calc(50% - 4.75px); }
.fsm__bar:last-child { top: calc(50% + 3.25px); }
.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; user-select: none; }
.fsm__links { list-style: none; position: relative; z-index: 1; }
.fsm__link { display: inline-block; text-decoration: none; color: inherit; }
.fsm__mask { display: block; overflow: hidden; padding: 0 .08em; margin: 0 -.08em; }
.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, font-variation-settings .5s; font-variation-settings: 'opsz' 144; }
.fsm__link:hover .fsm__text, .fsm__link:focus-visible .fsm__text { color: var(--knob-accent, var(--accent)); font-style: italic; }
.fsm__link:focus-visible { outline: none; }
.fsm__aside { position: relative; z-index: 1; display: grid; gap: 1.75rem; padding-bottom: .5rem; }
.fsm__label { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; opacity: .55; margin-bottom: .5rem; }
.fsm__small { display: block; font-size: .95rem; line-height: 1.5; text-decoration: none; color: inherit; width: fit-content; }
a.fsm__small:hover { color: var(--knob-accent, var(--accent)); }
a.fsm__small:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.fsm__page { padding-top: 0; }
.fsm__hero { min-height: 100vh; display: grid; align-content: center; padding: clamp(6rem, 12vh, 10rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 8vw, 6rem); max-width: 1400px; margin-inline: auto; }
.fsm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.fsm__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 7.5vw, 7.5rem); letter-spacing: -0.04em; line-height: .92; max-width: 14ch; }
.fsm__copy { margin-top: 2rem; max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.55; }
.fsm__copy kbd { font-family: var(--font-mono); font-size: .85em; padding: .1em .45em; border: 1px solid var(--line); border-radius: 4px; }
.fsm__filler { min-height: 60vh; display: grid; place-content: center; padding: 6vw; border-top: 1px solid var(--line); }
.fsm__filler-text { color: var(--muted); max-width: 40ch; text-align: center; font-size: .95rem; }
@media (max-width: 767px) {
.fsm__overlay { grid-template-columns: 1fr; align-items: start; align-content: space-between; gap: 2rem; overflow-y: auto; padding-top: 5.5rem; }
.fsm__text { font-size: clamp(2.5rem, 13vw, 4rem); }
.fsm__aside { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.fsm__group:last-child { grid-column: 1 / -1; }
.fsm__bgnum { font-size: 60vw; right: -0.04em; bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fsm__text, a.fsm__small { transition: none; }
}
```
```js
// Fullscreen Menu with Staggered Links — one paused timeline; open = play(), close = reverse().
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { curtain: 0.9, ease: 'power4.inOut', stagger: 0.07, linkDuration: 1, overlap: 0.45, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.fsm');
const burger = document.querySelector('.fsm__burger');
const bars = gsap.utils.toArray('.fsm__bar');
const overlay = document.querySelector('.fsm__overlay');
const texts = gsap.utils.toArray('.fsm__text');
const links = gsap.utils.toArray('.fsm__link');
const asideEls = gsap.utils.toArray('.fsm__aside .fsm__label, .fsm__aside .fsm__small');
const bgnum = document.querySelector('.fsm__bgnum');
let ctx;
function build() {
ctx?.revert(); // closes the menu instantly (timeline reverted) and runs the previous cleanup
root.style.setProperty('--knob-accent', K('accent'));
const CURTAIN = K('curtain');
const EASE = K('ease');
const STAGGER = K('stagger');
const LINK_DURATION = K('linkDuration');
const OVERLAP = Math.min(K('overlap'), CURTAIN); // links start this long before the curtain lands
let isOpen = false;
let autoOpenTimer;
// Resting (closed) attributes — a rebuild may happen while the menu was open.
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
gsap.from('.fsm__hero > *', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
}
const tl = gsap.timeline({
paused: true,
defaults: { ease: EASE },
onReverseComplete: () => gsap.set(overlay, { visibility: 'hidden' }),
});
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: CURTAIN }, 0)
.fromTo(texts, { yPercent: 110 }, { yPercent: 0, duration: LINK_DURATION, ease: 'power4.out', stagger: STAGGER }, CURTAIN - OVERLAP)
.fromTo(asideEls, { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: 'power3.out', stagger: 0.04 }, '<0.1');
const focusables = () => [burger, ...overlay.querySelectorAll('a[href]')];
function open() {
if (isOpen) return;
isOpen = true;
clearTimeout(autoOpenTimer);
burger.setAttribute('aria-expanded', 'true');
burger.setAttribute('aria-label', 'Close menu');
overlay.setAttribute('aria-hidden', 'false');
document.documentElement.style.overflow = 'hidden'; // body scroll lock
if (reduced) { tl.progress(1); gsap.set(bgnum, { opacity: 0.07 }); }
else {
tl.timeScale(1).play();
// The index number lives outside the main timeline so hover swaps never fight the open/close.
ctx.add(() => gsap.fromTo(bgnum, { yPercent: 12, opacity: 0 }, { yPercent: 0, opacity: 0.07, duration: 1.1, ease: 'power3.out', delay: 0.55, overwrite: 'auto' }));
}
links[0].focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = '';
reduced ? tl.progress(0) : tl.timeScale(1.5).reverse(); // close a touch faster than it opened
burger.focus({ preventScroll: true });
}
burger.addEventListener('click', () => (isOpen ? close() : open()), { signal: ac.signal });
// Escape closes; Tab is trapped inside burger + overlay links while open.
document.addEventListener('keydown', (e) => {
if (!isOpen) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') return;
const els = focusables();
const first = els[0], last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}, { signal: ac.signal });
// Background index follows the hovered / focused link.
let current = bgnum.textContent.trim();
function setIndex(n) {
if (n === current) return;
current = n;
if (reduced) { bgnum.textContent = n; return; }
ctx.add(() => 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' }));
}
links.forEach((link) => {
const n = link.dataset.index;
link.addEventListener('mouseenter', () => setIndex(n), { signal: ac.signal });
link.addEventListener('focus', () => setIndex(n), { signal: ac.signal });
link.addEventListener('click', (e) => { e.preventDefault(); close(); }, { signal: ac.signal });
});
// Demo only: open once by itself so the sequence is visible without a click (and again after a Playground change).
if (!reduced) autoOpenTimer = setTimeout(open, 700);
return () => {
clearTimeout(autoOpenTimer);
ac.abort(); // listeners go with the context so rebuilds never stack handlers
document.documentElement.style.overflow = '';
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (single HTML file): Fullscreen Menu with Staggered Links
Produce **one self-contained `index.html`** (inline `<style>` and `<script type="module">`, no build step) that implements **Fullscreen Menu with Staggered Links** exactly as specified below. Libraries load from jsDelivr:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fullscreen Menu with Staggered Links</title>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..600;1,9..144,300..600&family=Inter+Tight:wght@300..700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root{--bg:#0a0a0a;--surface:#121212;--line:rgba(255,255,255,.08);--text:#ededed;--muted:#8a8a8a;--accent:#d4ff3a;--font-display:"Fraunces",Georgia,serif;--font-sans:"Inter Tight",system-ui,sans-serif;--font-mono:"JetBrains Mono",ui-monospace,monospace}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font-family:var(--font-sans);-webkit-font-smoothing:antialiased;overflow-x:hidden}
/* + the CSS requirements below */
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
</head>
<body>
<!-- markup from the HTML structure section -->
<script type="module">
// the JS from the animation spec (window.MotionLab is optional — keep the ?. guards or remove them)
</script>
</body>
</html>
```
The file must open directly from a local server (`npx serve .`) with zero console errors, work at 375px and 1440px, and honour `prefers-reduced-motion`.
---
### 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.
---
**Reference implementation (use these exact values and class names):**
```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>
<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>
```
```css
.fsm { position: relative; }
.fsm__header { position: fixed; inset: 0 0 auto 0; z-index: 60; display: flex; align-items: center; justify-content: space-between; padding: clamp(1rem, 2.5vw, 1.75rem) clamp(1rem, 4vw, 3rem); mix-blend-mode: difference; color: #fff; }
.fsm__logo { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.fsm__logo-sep { opacity: .45; margin: 0 .1em; }
.fsm__burger { position: relative; width: 44px; height: 44px; margin-right: -10px; }
.fsm__burger:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; border-radius: 50%; }
.fsm__bar { position: absolute; left: 11px; width: 22px; height: 1.5px; background: #fff; border-radius: 1px; will-change: transform; }
.fsm__bar:first-child { top: calc(50% - 4.75px); }
.fsm__bar:last-child { top: calc(50% + 3.25px); }
.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; user-select: none; }
.fsm__links { list-style: none; position: relative; z-index: 1; }
.fsm__link { display: inline-block; text-decoration: none; color: inherit; }
.fsm__mask { display: block; overflow: hidden; padding: 0 .08em; margin: 0 -.08em; }
.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, font-variation-settings .5s; font-variation-settings: 'opsz' 144; }
.fsm__link:hover .fsm__text, .fsm__link:focus-visible .fsm__text { color: var(--knob-accent, var(--accent)); font-style: italic; }
.fsm__link:focus-visible { outline: none; }
.fsm__aside { position: relative; z-index: 1; display: grid; gap: 1.75rem; padding-bottom: .5rem; }
.fsm__label { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; opacity: .55; margin-bottom: .5rem; }
.fsm__small { display: block; font-size: .95rem; line-height: 1.5; text-decoration: none; color: inherit; width: fit-content; }
a.fsm__small:hover { color: var(--knob-accent, var(--accent)); }
a.fsm__small:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.fsm__page { padding-top: 0; }
.fsm__hero { min-height: 100vh; display: grid; align-content: center; padding: clamp(6rem, 12vh, 10rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 8vw, 6rem); max-width: 1400px; margin-inline: auto; }
.fsm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.fsm__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 7.5vw, 7.5rem); letter-spacing: -0.04em; line-height: .92; max-width: 14ch; }
.fsm__copy { margin-top: 2rem; max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.55; }
.fsm__copy kbd { font-family: var(--font-mono); font-size: .85em; padding: .1em .45em; border: 1px solid var(--line); border-radius: 4px; }
.fsm__filler { min-height: 60vh; display: grid; place-content: center; padding: 6vw; border-top: 1px solid var(--line); }
.fsm__filler-text { color: var(--muted); max-width: 40ch; text-align: center; font-size: .95rem; }
@media (max-width: 767px) {
.fsm__overlay { grid-template-columns: 1fr; align-items: start; align-content: space-between; gap: 2rem; overflow-y: auto; padding-top: 5.5rem; }
.fsm__text { font-size: clamp(2.5rem, 13vw, 4rem); }
.fsm__aside { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.fsm__group:last-child { grid-column: 1 / -1; }
.fsm__bgnum { font-size: 60vw; right: -0.04em; bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fsm__text, a.fsm__small { transition: none; }
}
```
```js
// Fullscreen Menu with Staggered Links — one paused timeline; open = play(), close = reverse().
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { curtain: 0.9, ease: 'power4.inOut', stagger: 0.07, linkDuration: 1, overlap: 0.45, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.fsm');
const burger = document.querySelector('.fsm__burger');
const bars = gsap.utils.toArray('.fsm__bar');
const overlay = document.querySelector('.fsm__overlay');
const texts = gsap.utils.toArray('.fsm__text');
const links = gsap.utils.toArray('.fsm__link');
const asideEls = gsap.utils.toArray('.fsm__aside .fsm__label, .fsm__aside .fsm__small');
const bgnum = document.querySelector('.fsm__bgnum');
let ctx;
function build() {
ctx?.revert(); // closes the menu instantly (timeline reverted) and runs the previous cleanup
root.style.setProperty('--knob-accent', K('accent'));
const CURTAIN = K('curtain');
const EASE = K('ease');
const STAGGER = K('stagger');
const LINK_DURATION = K('linkDuration');
const OVERLAP = Math.min(K('overlap'), CURTAIN); // links start this long before the curtain lands
let isOpen = false;
let autoOpenTimer;
// Resting (closed) attributes — a rebuild may happen while the menu was open.
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
gsap.from('.fsm__hero > *', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
}
const tl = gsap.timeline({
paused: true,
defaults: { ease: EASE },
onReverseComplete: () => gsap.set(overlay, { visibility: 'hidden' }),
});
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: CURTAIN }, 0)
.fromTo(texts, { yPercent: 110 }, { yPercent: 0, duration: LINK_DURATION, ease: 'power4.out', stagger: STAGGER }, CURTAIN - OVERLAP)
.fromTo(asideEls, { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: 'power3.out', stagger: 0.04 }, '<0.1');
const focusables = () => [burger, ...overlay.querySelectorAll('a[href]')];
function open() {
if (isOpen) return;
isOpen = true;
clearTimeout(autoOpenTimer);
burger.setAttribute('aria-expanded', 'true');
burger.setAttribute('aria-label', 'Close menu');
overlay.setAttribute('aria-hidden', 'false');
document.documentElement.style.overflow = 'hidden'; // body scroll lock
if (reduced) { tl.progress(1); gsap.set(bgnum, { opacity: 0.07 }); }
else {
tl.timeScale(1).play();
// The index number lives outside the main timeline so hover swaps never fight the open/close.
ctx.add(() => gsap.fromTo(bgnum, { yPercent: 12, opacity: 0 }, { yPercent: 0, opacity: 0.07, duration: 1.1, ease: 'power3.out', delay: 0.55, overwrite: 'auto' }));
}
links[0].focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = '';
reduced ? tl.progress(0) : tl.timeScale(1.5).reverse(); // close a touch faster than it opened
burger.focus({ preventScroll: true });
}
burger.addEventListener('click', () => (isOpen ? close() : open()), { signal: ac.signal });
// Escape closes; Tab is trapped inside burger + overlay links while open.
document.addEventListener('keydown', (e) => {
if (!isOpen) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') return;
const els = focusables();
const first = els[0], last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}, { signal: ac.signal });
// Background index follows the hovered / focused link.
let current = bgnum.textContent.trim();
function setIndex(n) {
if (n === current) return;
current = n;
if (reduced) { bgnum.textContent = n; return; }
ctx.add(() => 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' }));
}
links.forEach((link) => {
const n = link.dataset.index;
link.addEventListener('mouseenter', () => setIndex(n), { signal: ac.signal });
link.addEventListener('focus', () => setIndex(n), { signal: ac.signal });
link.addEventListener('click', (e) => { e.preventDefault(); close(); }, { signal: ac.signal });
});
// Demo only: open once by itself so the sequence is visible without a click (and again after a Playground change).
if (!reduced) autoOpenTimer = setTimeout(open, 700);
return () => {
clearTimeout(autoOpenTimer);
ac.abort(); // listeners go with the context so rebuilds never stack handlers
document.documentElement.style.overflow = '';
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());
```
### EFFECT (Framer & Webflow notes): Fullscreen Menu with Staggered Links
**Framer**
- Implement as a **Code Component** (Assets → Code → New component) when the effect owns its own markup (hero, gallery, section), or as a **Code Override** when it should animate existing Framer layers.
- Code Component: paste the React version (`"use client"` is not needed in Framer; keep `useGSAP` with a `scope` ref). Install `gsap` and `@gsap/react` from the package panel. Expose the customization knobs as `addPropertyControls` (numbers/colors/enums) so they are editable on the canvas.
- Code Override: `export function withFullscreenMenuwithStaggeredLinks(Component) { return (props) => { const ref = useRef(null); useGSAP(() => { /* spec */ }, { scope: ref }); return <Component ref={ref} {...props} /> } }` and apply it to the layer in the right-hand panel.
- Framer already smooth-scrolls; do **not** add Lenis.
- Preview in the Framer preview window, not the canvas (GSAP runs at runtime only).
**Webflow**
- Build the markup in the Designer with **exactly the class names from the HTML structure section** (prefix `fsm`), so the script finds the elements.
- Styles: either recreate the CSS requirements as Webflow classes or paste the CSS in **Page settings → Custom code → Inside <head>** inside `<style>`.
- Scripts: **Page settings → Before </body>**:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script type="module">
// paste the JS from the animation spec; remove the optional window.MotionLab?.… lines
</script>
```
- Native alternative: parts that are pure show/hide, fade or move can be Webflow Interactions (IX2); keep the timeline sequencing in code — IX2 cannot do stagger/ease control at this precision.
- Custom code only runs on the **published** site or in Preview, never on the Designer canvas.
- Webflow's own smooth scroll is off by default; if you add Lenis, sync it: `lenis.on('scroll', ScrollTrigger.update)`.
---
### 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>
.fsm { position: relative; }
.fsm__header { position: fixed; inset: 0 0 auto 0; z-index: 60; display: flex; align-items: center; justify-content: space-between; padding: clamp(1rem, 2.5vw, 1.75rem) clamp(1rem, 4vw, 3rem); mix-blend-mode: difference; color: #fff; }
.fsm__logo { font-family: var(--font-display); font-weight: 400; font-size: 1.15rem; letter-spacing: -0.02em; text-decoration: none; }
.fsm__logo-sep { opacity: .45; margin: 0 .1em; }
.fsm__burger { position: relative; width: 44px; height: 44px; margin-right: -10px; }
.fsm__burger:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; border-radius: 50%; }
.fsm__bar { position: absolute; left: 11px; width: 22px; height: 1.5px; background: #fff; border-radius: 1px; will-change: transform; }
.fsm__bar:first-child { top: calc(50% - 4.75px); }
.fsm__bar:last-child { top: calc(50% + 3.25px); }
.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; user-select: none; }
.fsm__links { list-style: none; position: relative; z-index: 1; }
.fsm__link { display: inline-block; text-decoration: none; color: inherit; }
.fsm__mask { display: block; overflow: hidden; padding: 0 .08em; margin: 0 -.08em; }
.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, font-variation-settings .5s; font-variation-settings: 'opsz' 144; }
.fsm__link:hover .fsm__text, .fsm__link:focus-visible .fsm__text { color: var(--knob-accent, var(--accent)); font-style: italic; }
.fsm__link:focus-visible { outline: none; }
.fsm__aside { position: relative; z-index: 1; display: grid; gap: 1.75rem; padding-bottom: .5rem; }
.fsm__label { font-family: var(--font-mono); font-size: 10.5px; text-transform: uppercase; letter-spacing: .14em; opacity: .55; margin-bottom: .5rem; }
.fsm__small { display: block; font-size: .95rem; line-height: 1.5; text-decoration: none; color: inherit; width: fit-content; }
a.fsm__small:hover { color: var(--knob-accent, var(--accent)); }
a.fsm__small:focus-visible { outline: 2px solid var(--knob-accent, var(--accent)); outline-offset: 2px; }
.fsm__page { padding-top: 0; }
.fsm__hero { min-height: 100vh; display: grid; align-content: center; padding: clamp(6rem, 12vh, 10rem) clamp(1rem, 5vw, 4rem) clamp(3rem, 8vw, 6rem); max-width: 1400px; margin-inline: auto; }
.fsm__eyebrow { font-family: var(--font-mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--muted); margin-bottom: 1.5rem; }
.fsm__title { font-family: var(--font-display); font-weight: 300; font-size: clamp(2.75rem, 7.5vw, 7.5rem); letter-spacing: -0.04em; line-height: .92; max-width: 14ch; }
.fsm__copy { margin-top: 2rem; max-width: 52ch; color: var(--muted); font-size: clamp(.95rem, 1.2vw, 1.15rem); line-height: 1.55; }
.fsm__copy kbd { font-family: var(--font-mono); font-size: .85em; padding: .1em .45em; border: 1px solid var(--line); border-radius: 4px; }
.fsm__filler { min-height: 60vh; display: grid; place-content: center; padding: 6vw; border-top: 1px solid var(--line); }
.fsm__filler-text { color: var(--muted); max-width: 40ch; text-align: center; font-size: .95rem; }
@media (max-width: 767px) {
.fsm__overlay { grid-template-columns: 1fr; align-items: start; align-content: space-between; gap: 2rem; overflow-y: auto; padding-top: 5.5rem; }
.fsm__text { font-size: clamp(2.5rem, 13vw, 4rem); }
.fsm__aside { grid-template-columns: 1fr 1fr; gap: 1.25rem; }
.fsm__group:last-child { grid-column: 1 / -1; }
.fsm__bgnum { font-size: 60vw; right: -0.04em; bottom: 0; }
}
@media (prefers-reduced-motion: reduce) {
.fsm__text, a.fsm__small { transition: none; }
}
// Fullscreen Menu with Staggered Links — one paused timeline; open = play(), close = reverse().
// Defaults — overridable from the MOTION/LAB Playground via MotionLab.knob()
const DEFAULTS = { curtain: 0.9, ease: 'power4.inOut', stagger: 0.07, linkDuration: 1, overlap: 0.45, accent: '#d4ff3a' };
const K = (id) => window.MotionLab?.knob(id, DEFAULTS[id]) ?? DEFAULTS[id];
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const root = document.querySelector('.fsm');
const burger = document.querySelector('.fsm__burger');
const bars = gsap.utils.toArray('.fsm__bar');
const overlay = document.querySelector('.fsm__overlay');
const texts = gsap.utils.toArray('.fsm__text');
const links = gsap.utils.toArray('.fsm__link');
const asideEls = gsap.utils.toArray('.fsm__aside .fsm__label, .fsm__aside .fsm__small');
const bgnum = document.querySelector('.fsm__bgnum');
let ctx;
function build() {
ctx?.revert(); // closes the menu instantly (timeline reverted) and runs the previous cleanup
root.style.setProperty('--knob-accent', K('accent'));
const CURTAIN = K('curtain');
const EASE = K('ease');
const STAGGER = K('stagger');
const LINK_DURATION = K('linkDuration');
const OVERLAP = Math.min(K('overlap'), CURTAIN); // links start this long before the curtain lands
let isOpen = false;
let autoOpenTimer;
// Resting (closed) attributes — a rebuild may happen while the menu was open.
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
ctx = gsap.context(() => {
const ac = new AbortController();
if (!reduced) {
gsap.from('.fsm__hero > *', { y: 24, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.08, delay: 0.1 });
}
const tl = gsap.timeline({
paused: true,
defaults: { ease: EASE },
onReverseComplete: () => gsap.set(overlay, { visibility: 'hidden' }),
});
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: CURTAIN }, 0)
.fromTo(texts, { yPercent: 110 }, { yPercent: 0, duration: LINK_DURATION, ease: 'power4.out', stagger: STAGGER }, CURTAIN - OVERLAP)
.fromTo(asideEls, { y: 12, opacity: 0 }, { y: 0, opacity: 1, duration: 0.6, ease: 'power3.out', stagger: 0.04 }, '<0.1');
const focusables = () => [burger, ...overlay.querySelectorAll('a[href]')];
function open() {
if (isOpen) return;
isOpen = true;
clearTimeout(autoOpenTimer);
burger.setAttribute('aria-expanded', 'true');
burger.setAttribute('aria-label', 'Close menu');
overlay.setAttribute('aria-hidden', 'false');
document.documentElement.style.overflow = 'hidden'; // body scroll lock
if (reduced) { tl.progress(1); gsap.set(bgnum, { opacity: 0.07 }); }
else {
tl.timeScale(1).play();
// The index number lives outside the main timeline so hover swaps never fight the open/close.
ctx.add(() => gsap.fromTo(bgnum, { yPercent: 12, opacity: 0 }, { yPercent: 0, opacity: 0.07, duration: 1.1, ease: 'power3.out', delay: 0.55, overwrite: 'auto' }));
}
links[0].focus({ preventScroll: true });
}
function close() {
if (!isOpen) return;
isOpen = false;
burger.setAttribute('aria-expanded', 'false');
burger.setAttribute('aria-label', 'Open menu');
overlay.setAttribute('aria-hidden', 'true');
document.documentElement.style.overflow = '';
reduced ? tl.progress(0) : tl.timeScale(1.5).reverse(); // close a touch faster than it opened
burger.focus({ preventScroll: true });
}
burger.addEventListener('click', () => (isOpen ? close() : open()), { signal: ac.signal });
// Escape closes; Tab is trapped inside burger + overlay links while open.
document.addEventListener('keydown', (e) => {
if (!isOpen) return;
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
if (e.key !== 'Tab') return;
const els = focusables();
const first = els[0], last = els[els.length - 1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}, { signal: ac.signal });
// Background index follows the hovered / focused link.
let current = bgnum.textContent.trim();
function setIndex(n) {
if (n === current) return;
current = n;
if (reduced) { bgnum.textContent = n; return; }
ctx.add(() => 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' }));
}
links.forEach((link) => {
const n = link.dataset.index;
link.addEventListener('mouseenter', () => setIndex(n), { signal: ac.signal });
link.addEventListener('focus', () => setIndex(n), { signal: ac.signal });
link.addEventListener('click', (e) => { e.preventDefault(); close(); }, { signal: ac.signal });
});
// Demo only: open once by itself so the sequence is visible without a click (and again after a Playground change).
if (!reduced) autoOpenTimer = setTimeout(open, 700);
return () => {
clearTimeout(autoOpenTimer);
ac.abort(); // listeners go with the context so rebuilds never stack handlers
document.documentElement.style.overflow = '';
};
});
}
build();
// Playground: rebuild with the new values
window.addEventListener('ml:knobs', build);
window.MotionLab?.onCleanup(() => ctx?.revert());