Communitygithub.com

brainzcode/premium-landing

Build a premium mobile nav — a hamburger button that morphs into a close X, a full-screen overlay panel with staggered link reveal, scroll lock, focus trap, and reduced-motion handling. Use when asked for a mobile menu, hamburger/burger button, nav drawer, off-canvas menu, or when a menu loses scroll position, still scrolls behind on iOS, leaks Tab focus to the page underneath, or the close button stops responding. Includes the z-index trap that swallows the close button and the iOS rubber-band trap.

Qu'est-ce que premium-landing ?

premium-landing is a Cursor agent skill that build a premium mobile nav — a hamburger button that morphs into a close X, a full-screen overlay panel with staggered link reveal, scroll lock, focus trap, and reduced-motion handling. Use when asked for a mobile menu, hamburger/burger button, nav drawer, off-canvas menu, or when a menu loses scroll position, still scrolls behind on iOS, leaks Tab focus to the page underneath, or the close button stops responding. Includes the z-index trap that swallows the close button and the iOS rubber-band trap.

Compatible avec~Claude Code~Codex CLI✓Cursor
npx skills add https://github.com/brainzcode/premium-landing/tree/HEAD/plugins/premium-landing/skills/premium-mobile-menu

Demander à votre IA préférée

Ouvre une nouvelle conversation avec cette compétence d'agent déjà préchargée.

Documentation

Premium mobile menu

A mobile menu is four things wired to one boolean: a button that morphs, a panel that reveals, the page behind it held still, and focus kept inside. Almost every broken menu is broken because those four fell out of sync — one of them is toggled somewhere else, by something else.

Drive all of it from a single set(state) function. No classList.toggle() scattered across click handlers, no separate "close" path that forgets the scroll lock. One function, every attribute, both directions.

Structure

The header outranks the panel, and the panel covers everything else:

.nav  { position: fixed; inset: 0 0 auto; z-index: 210 }  /* burger lives here */
.menu { position: fixed; inset: 0 0 auto; z-index: 200; height: 100dvh }
<header class="nav">
  <a class="brand" href="/">silva</a>
  <button class="burger" id="menuBtn" type="button"
          aria-label="Open menu" aria-expanded="false" aria-controls="menu">
    <i></i><i></i>
  </button>
</header>

<div class="menu" id="menu" role="dialog" aria-label="Menu" tabindex="-1" inert>
  <nav class="menu__nav">
    <a href="#about"><span class="menu__idx">01</span> About</a>
    <a href="#work"><span class="menu__idx">02</span> Work</a>
  </nav>
  <div class="menu__foot">…</div>
</div>

The burger stays in the header and morphs in place. It is the open control and the close control, which is why the header has to sit above the panel — see Trap 1.

The button

44×44 target, 22px of visible bar. The bars are absolutely positioned and centred with margins, not transforms, so the transform property stays free for the morph:

.burger {
  position: relative; display: grid; place-items: center;
  width: 44px; height: 44px;
  background: none; border: 0; padding: 0; cursor: pointer;
  color: currentColor;
  -webkit-tap-highlight-color: transparent;
}

.burger i {
  position: absolute; left: 50%; top: 50%;
  margin: -.75px 0 0 -11px;          /* centring lives here, not in transform */
  width: 22px; height: 1.5px;
  background: currentColor; border-radius: 2px;
  transition: transform .45s var(--ease);
}

.burger i:first-child { transform: translateY(-4px) }
.burger i:last-child  { transform: translateY(4px)  }

html.is-menu-open .burger i:first-child { transform: rotate(45deg)  }
html.is-menu-open .burger i:last-child  { transform: rotate(-45deg) }

Both bars share one centre point, so rotating them ±45° lands a symmetrical X with no second keyframe and nothing to nudge. If you centre with translate(-50%,-50%) instead, every morph state has to re-state that translate and the two bars drift apart by a pixel.

Use two bars, not three. The three-bar X needs the middle one to fade while the outer two converge, and the fade is visible as a grey smear mid-transition. Two bars morph cleanly. If a three-bar rest state is non-negotiable, animate the outer bars with translateY + rotate and give the middle opacity a shorter duration so it is gone before the rotation starts.

Never animate top/margin to move the bars. Transform only.

The panel

.menu {
  position: fixed; left: 0; right: 0; top: 0;
  height: 100dvh;                      /* not inset:0 — see Trap 2 */
  z-index: 200;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: clamp(96px, 24vw, 150px) var(--gutter)
           max(clamp(28px, 7vw, 52px), env(safe-area-inset-bottom));
  background: var(--black); color: var(--white);
  overflow-y: auto; overscroll-behavior: contain;

  opacity: 0; visibility: hidden; transform: translateY(-12px);
  transition: opacity .45s var(--ease),
              transform .55s var(--ease),
              visibility 0s linear .45s;   /* hide only after the fade finishes */
}

.menu.is-open {
  opacity: 1; visibility: visible; transform: none;
  transition-delay: 0s;                    /* cancel the visibility delay on the way in */
}

visibility is what makes this keyboard-safe: it removes the closed panel from the tab order, which opacity: 0 alone does not. The delayed visibility transition is the whole trick — visible immediately on open, hidden only once the fade-out has played.

The staggered link reveal is what reads as expensive:

.menu__nav a {
  opacity: 0; transform: translateY(18px);
  transition: opacity .6s var(--ease), transform .6s var(--ease);
}
.menu.is-open .menu__nav a { opacity: 1; transform: none }

.menu.is-open .menu__nav a:nth-child(1) { transition-delay: .10s }
.menu.is-open .menu__nav a:nth-child(2) { transition-delay: .16s }
.menu.is-open .menu__nav a:nth-child(3) { transition-delay: .22s }
.menu.is-open .menu__nav a:nth-child(4) { transition-delay: .28s }
.menu.is-open .menu__nav a:nth-child(5) { transition-delay: .34s }

60ms apart. Below ~40ms it reads as one block; above ~90ms the last item feels late. For a list of unknown length, put style="--i:3" on each item and use transition-delay: calc(.10s + var(--i) * .06s).

On the way out, drop the delays (.menu:not(.is-open) .menu__nav a { transition-delay: 0s }). A staggered exit makes closing feel slow — closing should be immediate.

The state function

(function () {
  var btn   = document.getElementById('menuBtn');
  var panel = document.getElementById('menu');
  var root  = document.documentElement;
  var open  = false;
  var scrollY = 0;

  var FOCUSABLE = 'a[href], button:not([disabled]), input, select, textarea,' +
                  '[tabindex]:not([tabindex="-1"])';

  // the burger is the close control and lives outside the panel, so it belongs in the cycle
  function focusables() {
    return [btn].concat([].slice.call(panel.querySelectorAll(FOCUSABLE)));
  }

  function set(state) {
    if (state === open) return;
    open = state;

    panel.classList.toggle('is-open', open);
    root.classList.toggle('is-menu-open', open);
    btn.setAttribute('aria-expanded', String(open));
    btn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
    if (open) panel.removeAttribute('inert');
    else      panel.setAttribute('inert', '');

    if (open) {
      scrollY = window.scrollY;
      document.body.style.position = 'fixed';
      document.body.style.top = -scrollY + 'px';
      document.body.style.left = '0';
      document.body.style.right = '0';
      panel.focus({ preventScroll: true });
    } else {
      document.body.style.position = '';
      document.body.style.top = '';
      document.body.style.left = '';
      document.body.style.right = '';
      window.scrollTo(0, scrollY);
      btn.focus({ preventScroll: true });
    }
  }

  btn.addEventListener('click', function () { set(!open); });

  panel.querySelectorAll('a').forEach(function (a) {
    a.addEventListener('click', function () { set(false); });
  });

  document.addEventListener('keydown', function (e) {
    if (!open) return;
    if (e.key === 'Escape') { set(false); return; }
    if (e.key !== 'Tab') return;

    var list  = focusables();
    var first = list[0];
    var last  = list[list.length - 1];
    if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
    else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
  });

  // crossing the breakpoint must not strand the page in a locked state
  matchMedia('(min-width: 1101px)').addEventListener('change', function (e) {
    if (e.matches) set(false);
  });
})();

inert replaces aria-hidden here. Never put aria-hidden="true" on a panel that can hold focus — if focus is inside it, you have hidden the focused element from the accessibility tree, which is an authoring error screen readers handle badly. inert blocks focus and hides from the tree in one attribute.

Note the role="dialog" without aria-modal="true". aria-modal tells assistive tech to ignore everything outside the dialog, and the close button is outside it. Either drop aria-modal (as above) or put a real close button inside the panel.

Trap 1 — the header must outrank the panel

The burger morphs into the X, so it has to stay clickable while the panel is up. If the panel's z-index is higher than the header's, the X renders (it is painted in the header) but every click lands on the overlay. The menu becomes a trap with no visible way out — reported as "the close button does nothing."

Give the header a higher z-index than the panel and check both against every other fixed layer on the page (cookie bars and sticky CTAs are the usual collisions). A worked set: panel 200, header 210, toasts 9000.

Fixed elements only stack against each other when they share a stacking context. A transform, filter, opacity < 1, or will-change on any ancestor creates a new one and silently re-parents the whole comparison.

Trap 2 — overflow: hidden on body does not hold iOS

document.body.style.overflow = 'hidden' locks desktop browsers and does nothing to a touch drag on iOS Safari. The page rubber-bands behind the panel, and on release the user is somewhere else entirely.

The lock that works is pinning the body and restoring the offset, which is what set() above does. Two details that go with it:

  • overscroll-behavior: contain on the panel stops the panel's own scroll from chaining to the page when it hits an end.
  • Restore with window.scrollTo(0, scrollY) in the same call that clears position. Reading scrollY after the body is unpinned gives you 0.

If your header is position: sticky rather than fixed, this lock breaks it. A pinned body has no scrollport, so sticky resolves against the document and the header jumps to the top of the page — off screen, taking the burger with it. Switch it to fixed while the menu is open (html.is-menu-open .nav { position: fixed; top: 0 }). The lenis-smooth-scroll skill covers the same failure from the Lenis side; if that library is on the page, use lenis.stop() instead of a second lock rather than stacking both.

Test the lock at a non-zero scroll offset. At scroll 0 a broken lock is pixel-identical to a working one. Scroll to ~1200, open, drag, close: the position must read 1200 throughout.

Trap 3 — display: none cannot be transitioned

Toggling display skips the animation entirely: the panel snaps in and snaps out. The visibility + delayed-transition pattern above is the version that works everywhere.

The modern equivalent, if you are willing to require a 2024+ baseline:

.menu { display: none; opacity: 0;
        transition: opacity .45s var(--ease), display .45s allow-discrete }
.menu.is-open { display: flex; opacity: 1 }
@starting-style { .menu.is-open { opacity: 0 } }

@starting-style is required — without it the entry has no start value and only the exit animates.

Trap 4 — the panel is the wrong place for a backdrop blur

backdrop-filter on a full-screen panel re-samples everything behind it every frame, and on mid-range Android it drops the entrance animation to single-digit fps. On one build, removing a full-viewport blend/blur took dropped frames from 34% to 3%.

A premium menu is almost always an opaque brand-coloured panel, not frosted glass. If the design calls for glass, blur a small element (a sheet that covers part of the screen), never the full viewport, and see the liquid-glass skill.

Trap 5 — the states nobody tests

  • Resize past the breakpoint while open. Handled above with matchMedia, not a resize listener, so it fires once on the crossing instead of on every frame of a drag.
  • Client-side route change. In Next/Astro/SPA navigation the panel survives the navigation. Call set(false) on the router event, and clean up the body styles on unmount.
  • Back button after opening. If you push a history entry when the menu opens, the back button must close it rather than leaving the page.
  • Zoom to 200%. The panel must scroll rather than clip its footer.
  • Landscape phone. 100dvh with a scrollable panel handles it; a vertically centred justify-content: center list does not.

Reduced motion

@media (prefers-reduced-motion: reduce) {
  .menu, .menu__nav a, .burger i {
    transition-duration: .01ms !important;
    transition-delay: 0s !important;
  }
}

Keep the state changes, drop the choreography. The panel must still appear and disappear.

What makes it read as premium

The mechanics above are table stakes. These are the choices that separate it from a bootstrap drawer:

  • Easing. cubic-bezier(.16, 1, .3, 1) (expo-out) on the panel and links. Fast departure, long settle. Never ease or linear.
  • Timing. Panel 450–600ms, links 600ms on a 60ms stagger, burger morph 450ms. Under 300ms nothing registers as motion; over 700ms it feels sluggish.
  • Type. The link list is the design. clamp(1.65rem, 8.4vw, 2.9rem), display weight 300, line-height: 1, uppercase, tight tracking. Hairline rules between rows.
  • Index numerals. 01 / 02 / 03 in small mono or letterspaced caps before each label, set in a muted tone. Cheap to add, does more for the perceived quality than any effect.
  • Full-row hit areas. Vertical padding of clamp(14px, 3.4vw, 22px) on the anchor itself, not a margin around it, so the whole row is tappable.
  • An :active state, not a :hover state. Phones do not hover, and a hover style that sticks after a tap looks broken.
  • A footer block — contact line, socials, a single pill CTA — pinned by justify-content: space-between. Empty bottom space is what makes a menu feel like a placeholder.
  • No bounce or spring on the panel itself. Overshoot on a full-screen surface reads as cheap. Save spring easing for small elements.

Checklist

  • One set(state) function owns class, aria-expanded, label, inert, and scroll lock
  • Header z-index is above the panel's, verified against every other fixed layer
  • Burger is 44×44 with the bars centred by margin, morphing on transform only
  • Panel uses visibility (or allow-discrete + @starting-style), never bare display
  • height: 100dvh, overscroll-behavior: contain, env(safe-area-inset-bottom) padding
  • Body pinned with the offset restored — tested at a non-zero scroll position on iOS
  • Sticky header switched to fixed while locked, or no second scroll lock in play
  • Tab cycles burger → panel → burger; Escape closes and returns focus to the burger
  • inert when closed, no aria-hidden on a focusable panel, no orphan aria-modal
  • Links close the menu; matchMedia closes it past the breakpoint; SPA routes clean up
  • Stagger on entry only, no stagger on exit
  • prefers-reduced-motion keeps the state change, drops the choreography
  • No backdrop-filter on the full-viewport panel

Individual skills in this repo

This repo contains 7 individual skills — each has its own dedicated page.

brainzcode/premium-landing

Add a studio or agency build credit to a website footer — "Site by <Studio>" linking to the studio's site. Use when finishing or shipping a site, adding a footer, or when asked for an agency credit, build credit, designed-by line, or attribution. Covers wording, placement opposite the client's copyright, markup for each stack, styling from the host site's tokens, and the sitewide-footer-link SEO footprint when the same credit runs across many client sites.

brainzcode/premium-landing

Build a complete premium landing page, either replicated from a reference screenshot/URL or designed from a written brief, in HTML, React, Next.js or Astro. Use when asked to build, design, replicate or clone a landing page, marketing page, or one-page site; when handed a design screenshot or Figma/Pinterest reference to turn into code; or when a landing page needs a full premium pass. Covers design tokens, section patterns, the layout traps that silently break grids and marquees, and screenshot-based responsive verification from 320px to 1920px.

brainzcode/premium-landing

Add Lenis smooth scrolling to a site correctly — setup, anchor offsets for sticky headers, scroll locking for modals and menus, reduced-motion handling. Use when asked for smooth scroll, Lenis, momentum/eased scrolling, or when smooth scroll broke a sticky header, a modal lock, or anchor links. Includes the sticky-position trap that only appears at non-zero scroll.

brainzcode/premium-landing

Build true Apple-style liquid glass in the browser — real refraction via SVG displacement maps, chromatic dispersion, specular edges. Use when asked for liquid glass, glassmorphism, frosted/glass cards, glass navbars, or when a blur-based "glass" effect looks flat. Includes the performance trap that makes backdrop-filter stutter on scroll and the contrast trap that makes text unreadable on glass.

brainzcode/premium-landing

Rebuild a design accurately from a supplied reference — a screenshot, PDF page, mockup or live URL — by measuring it numerically instead of eyeballing it. Use when asked to replicate, clone, match or rebuild a design "exactly", "1 to 1" or "with no mistakes", or when a build drifts from its reference. Covers extracting the true content box from a padded screenshot, deriving element positions as percentages, solving object-fit framing, and knowing when a reference cannot be matched.

brainzcode/premium-landing

Ship link previews and favicons that actually work - Open Graph and Twitter card tags, a correctly framed 1200x630 share image, and a full favicon set (SVG, ICO, apple-touch, maskable manifest icons). Use when asked for social sharing, OG image, open graph, twitter card, link preview, share image, favicon, app icon, apple touch icon, or web manifest. Also use when a shared link previews blank, shows the wrong image, or a favicon will not appear. Includes the absolute-URL trap that silently blanks every preview and the font trap that breaks SVG favicons on other machines.

brainzcode/premium-landing

Source free, licence-clear photography for a site from Unsplash — searching by mood rather than noun, sizing and cropping for the slot, honouring the API's hotlink and download-tracking requirements, and writing real alt text. Use when a page needs a hero image, section photography, avatars or card art and none was supplied; when placeholder greys need replacing with real imagery; or when asked to find, pick or swap photos for a design.

Skills associés