Skip to content

Mega menu: the collapsed height holds before the script - #34

Merged
ecropolis merged 1 commit into
mainfrom
claude/mega-menu-no-shift
Oct 1, 2026
Merged

ecropolis merged 1 commit into
mainfrom
claude/mega-menu-no-shift

Conversation

@ecropolis

Copy link
Copy Markdown
Owner

Why

On phones the mega menu was served with every top link showing and the Menu button hidden. The script then folded the list away. On superherotech.ai at 412px the header was 233px tall as served and 69px after the script, so the page below jumped up 164px. PageSpeed reported this as CLS 0.307 on every page, or as a late LCP when the hero started below the fold. Every site that uses this element has the same problem.

What changed (src/library/mega-menu/MegaMenu.astro)

  • Collapsed from the first paint. Each nav now opens with a <style> keyed on its own data-breakpoint:
    @media (scripting: enabled) and (max-width: <breakpoint − 0.02>px) { .mm:not(.mm--js)[data-breakpoint="N"] > .mm__toggle { display: inline-flex } … > .mm__bar { display: none } }
    It asks the same question as the script's matchMedia. Each breakpoint gets its own rule, so two menus with different breakpoints on one page don't collide. :not(.mm--js) hands control to the script's classes once it runs. Breakpoint 0 emits nothing.
  • Menu button no longer served hidden. The stylesheet hides .mm__toggle and shows it in the collapsed layout only (.mm--compact, or the pre-script rule). The script no longer unhides it. No new !important.
  • Without JavaScript nothing changes. The full list of links shows and there is no Menu button. A browser that doesn't support the scripting media feature shows the full list until the script folds it, as before: it still shifts, but the menu works.
  • Wide screens: same width before and after the script. A panel item's link now carries the same caret as the button that replaces it. .mm__top, .mm__toggle are box-sizing: border-box and the button inherits letter-spacing, word-spacing and text-transform, so the swap no longer widens the bar. Before, it grew 40px on the site at 1280px.
  • The Menu button works as early as possible. A collapsed menu is set up the moment the browser reaches its script, right after the nav, instead of at DOMContentLoaded. A wide menu still waits for DOMContentLoaded, because its served links already work.
  • Known limit, now in the docs: below the breakpoint the menu needs its script. On a page whose CSP refuses the script, the Menu button does nothing.
  • No public props, slots, data attributes or class names changed. The docs are updated in the component header and the catalog (breakpoint note plus one accessibility line).

Check (scripts/check-mega-menu.mjs, new stage "5. no shift")

  • Runs the frontmatter's // <mm-prescript> block for breakpoints 960, 720, 1040 and "1200". Each must give one (scripting: enabled) rule at exactly the script's matchMedia threshold, computed from the script's own source, keyed on that breakpoint. The rule must show the toggle and fold the list. 1040 and 720 must not name each other, and 0 must emit nothing.
  • The nav opens with the rule. The built page's nav carries it at its own breakpoint, and its Menu button is not served hidden.
  • The script never sets toggle.hidden. .mm__toggle is display: none except under .mm--compact. Only .mm--compact hides .mm__bar.
  • A panel link carries the caret, .mm__top, .mm__toggle is border-box and inherits the text settings, start() runs at once, and every matchMedia in the script asks the same question.
  • 45/45 mutants killed, up from 23. npm run build && npm run check passes.

Measurements (headless Chromium, layout-shift entries collected with a buffered PerformanceObserver)

Method. The menu's script is held back 1.5 s (its body wrapped in setTimeout, whether it ships inline or as a CSP-moved /_astro/inline/*.js). The page as served is measured with scripting on, then again after the script.

Page · width Before: header as served → after script Before: CLS After: served → after After: CLS
Site / · 412 231.75 → 69 px 0.282 69 → 69 0
Site / · 1280 71.75 → 71.75 (bar width 588 → 628) 0.0008 71.75 → 71.75 (bar width 628 → 628) 0
Site /elements/mega-menu/ · 412 231.75 → 69 0.282 69 → 69 0
Site /elements/mega-menu/ · 800 (site menu at 1040 collapsed, demo menu at 720 wide) site header collapses 0.640 site 73 → 73, demo 61.05 → 61.05 (bar width 536 → 536) 0
Site /elements/mega-menu/ · 1280 71.75 → 71.75 (bar width 588 → 628) 0.0014 71.75 → 71.75 0
Library /mega-menu/ · 412 153.92 → 61.05 0.058 61.05 → 61.05 0
Library /mega-menu/ · 1280 61.05 → 61.05 (bar width 508 → 536) 0.0004 61.05 → 61.05 (bar width 536 → 536) 0

Without JavaScript at 412px: the full list shows, no Menu button, site header 231.75px as before.

Unmodified pages with the CPU throttled 6×:

  • At 412px: header shift in 0 of 12 runs after the fix. Before: 12 of 12 runs, mean CLS 0.284.
  • At 1280px, site /: the horizontal shift from the link-to-button swap, present in every run before, is gone.
  • At 1280px, a 1px vertical shift of the site's nav (about 0.003) still shows in some runs: 5 of 20 before, 6 of 20 after. It is not caused by the menu. Chrome paints the site's header mid-parse, before .header__actions (46.75px, after the nav in the markup) exists, and the header then grows from 69 to 71.75px. It also appears with the menu's script removed. Fixing it is a site change: give .header__bar a minimum height of the button's height above 1040px.

Functional (site build, Playwright):

  • 412: Menu opens the list, a panel opens as an accordion, Escape closes, no page errors.
  • 1280: top items are buttons and a click opens a panel.
  • No JavaScript at 412: all 6 top links show and there is no Menu button.

Site (superherotech.ai)

Built from Website origin/main in a separate worktree with ELEMENTS_SOURCE=<this branch>. npm run build and npm run check both pass. No site check pins the hidden toggle or other markup this changes, so the site needs no update. Nothing was pushed or deployed. The site picks this up at its next build after merge.

🤖 Generated with Claude Code

Below its breakpoint the menu was served with the full list showing and the
Menu button hidden; the script then folded the list away. On superherotech.ai
at 412px the header went from 233px to 69px when the script ran (PageSpeed CLS
0.307 on every page, or a late LCP).

- Each nav now opens with a <style> keyed on its data-breakpoint:
  @media (scripting: enabled) and (max-width: <breakpoint - 0.02>px), the
  script's own matchMedia question, shows the Menu button and folds the list
  until the script takes over (.mm:not(.mm--js)). Without JavaScript the full
  list shows and there is no Menu button, as before.
- The Menu button is no longer served hidden; the stylesheet shows it in the
  collapsed layout only, and the script no longer unhides it.
- Above the breakpoint a panel item's link carries the caret of the button
  that replaces it, and both are border-box with the same text settings, so
  the bar keeps its width when the script swaps them (it grew 40px on the
  site at 1280px).
- A collapsed menu is set up the moment the browser reaches its script, so
  its Menu button is live as early as possible; a wide menu still waits for
  DOMContentLoaded.
- check-mega-menu pins all of it (45/45 mutants killed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ecropolis
ecropolis merged commit e16eea1 into main Oct 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant