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. 1405 words of exact values — targets, eases, durations, triggers.
### 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 — 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, 70ms 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 lime 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)`.
- 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.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: lime + 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 lime curtain.
- 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 at 0.45s with 70ms 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. 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. 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(--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(--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(--accent); }
a.fsm__small:focus-visible { outline: 2px solid 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().
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
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 isOpen = false;
let autoOpenTimer;
const ctx = gsap.context(() => {
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: 'power4.inOut' },
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: 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');
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.
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()));
// Escape closes; Tab is trapped inside burger + overlay links while open.
const onKey = (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(); }
};
document.addEventListener('keydown', onKey);
// Background index follows the hovered / focused link.
let current = '01';
function setIndex(n) {
if (n === current) return;
current = n;
if (reduced) { bgnum.textContent = n; return; }
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));
link.addEventListener('focus', () => setIndex(n));
link.addEventListener('click', (e) => { e.preventDefault(); close(); });
});
// Demo only: open once by itself so the sequence is visible without a click.
if (!reduced) autoOpenTimer = setTimeout(open, 700);
return () => {
clearTimeout(autoOpenTimer);
document.removeEventListener('keydown', onKey);
document.documentElement.style.overflow = '';
};
});
window.MotionLab?.onCleanup(() => ctx.revert());