Skip to main content

Merge duplicate mobile and desktop menus into one

Use a single responsive menu with CSS media queries instead of duplicate menus, or use hybrid rendering with Liquid for visible parts and JavaScript for hidden parts.


Excessive DOM nodes have direct performance consequences. They slow HTML parsing and style calculations, slow initial render, which directly harms FCP, increase memory usage on user devices, and slow DOM manipulation, which can harm INP. The most common culprit is duplicate menus for mobile and desktop: one menu hidden by CSS and another shown, which doubles the DOM nodes.

A navigation element with 500 to 2,000 DOM nodes is common for themes with duplicate menus. Reducing this by half directly reduces HTML parsing, style calculation, and interaction latency.


Anchor to Solution 1: Single menu with responsive CSSSolution 1: Single menu with responsive CSS

Instead of duplicate menus, render the navigation links once and vary the presentation with CSS media queries. This reduces DOM size by half because the markup exists only one time.

sections/header.liquid

<nav class="site-nav" aria-label="Primary">
<button class="site-nav__toggle" aria-expanded="false" aria-controls="site-nav-menu">
Menu
</button>

<ul id="site-nav-menu" class="site-nav__menu">
{%- for link in linklists.main-menu.links -%}
<li class="site-nav__item">
<a href="{{ link.url }}" class="site-nav__link">{{ link.title }}</a>
</li>
{%- endfor -%}
</ul>
</nav>

The same markup renders a drawer on mobile and a horizontal bar on desktop. The media query controls which layout applies.

assets/header.css

/* Mobile first: hidden drawer toggled by the menu button. */
.site-nav__menu {
display: none;
flex-direction: column;
gap: 0.5rem;
}

.site-nav__toggle[aria-expanded='true'] + .site-nav__menu {
display: flex;
}

/* Desktop: horizontal bar, toggle button hidden. */
@media (min-width: 750px) {
.site-nav__toggle {
display: none;
}

.site-nav__menu {
display: flex;
flex-direction: row;
gap: 1.5rem;
}
}

The toggle button drives the drawer with aria-expanded so the control stays operable by keyboard and exposes its state to assistive technology.

sections/header.liquid

<script>
const toggle = document.querySelector('.site-nav__toggle');

toggle.addEventListener('click', () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
toggle.setAttribute('aria-expanded', String(!expanded));
});
</script>

Anchor to Solution 2: Hybrid renderingSolution 2: Hybrid rendering

For complex mega-menus that can't be simplified, a hybrid rendering approach is effective:

  • Render visible parts with Liquid: the top-level navigation links render on the server for immediate availability and discovery by search engines.
  • Fetch hidden parts with JavaScript: JavaScript fetches the contents of submenus asynchronously when the user hovers over or clicks a top-level item.
  • Provide a fallback: make sure a link to a collection page or a fully server-rendered menu is available for search bots and users without JavaScript.

Render only the top-level links on initial load. Place the full mega-menu (with submenus) in its own section file. When the user hovers or clicks a top-level item, use the Section Rendering API to fetch that section's HTML and inject it. Because the complex submenu Liquid doesn't run on initial page load, this reduces HTML size and parse cost.

The top-level header renders lightweight links:

sections/header.liquid

<nav class="mega-nav" aria-label="Primary">
<ul class="mega-nav__menu">
{%- for link in linklists.main-menu.links -%}
<li class="mega-nav__item">
<a href="{{ link.url }}" class="mega-nav__link">{{ link.title }}</a>
</li>
{%- endfor -%}
</ul>
<div id="mega-nav-submenu" hidden></div>
</nav>

On hover, fetch the full mega-menu section. The Section Rendering API returns the rendered HTML for any section by name. The fetched section receives the same page context (product, collection, and so on) as the initial render, so its Liquid has access to the data it needs:

assets/mega-menu.js

let loaded = false;

async function loadMegaMenu() {
if (loaded) return;
loaded = true;

const url = `${window.location.pathname}?sections=mega-menu-full`;
const response = await fetch(url);
const data = await response.json();

const container = document.getElementById('mega-nav-submenu');
container.innerHTML = data['mega-menu-full'];
container.hidden = false;
}

document.querySelector('.mega-nav').addEventListener('mouseenter', loadMegaMenu);
document.querySelector('.mega-nav').addEventListener('focusin', loadMegaMenu);

Create a separate section (sections/mega-menu-full.liquid) that contains the full nested menu markup. This section's Liquid executes only when fetched through the Section Rendering API, not on the initial page load.

This pattern guards expensive menu Liquid behind interaction, which is similar to the approach in Defer dialog content loading until user interaction. See also Avoid deeply nested Liquid loops.


To identify excessive DOM in your navigation:

  1. Inspect the navigation element in Chrome DevTools.
  2. Open the Console.
  3. Run $0.querySelectorAll('*').length.
  4. The Lighthouse DOM size audit flags pages that exceed 1,500 DOM elements as a warning. If a single navigation element approaches that threshold, then it's likely an issue.

Compare the node count before and after merging the menus. Before: inspect the navigation and run $0.querySelectorAll("*").length. A count above 500 suggests duplicate menus. After: the same measurement should show roughly half the nodes.

A typical mega-menu hybrid approach loads the top-level categories on page load, and then fetches category dropdowns through JavaScript when a user hovers.


  • Count DOM nodes in the navigation: inspect the <nav> element, then run $0.querySelectorAll('*').length in the Console before and after your change.
  • Compare FCP before and after simplification: record a trace in the Performance panel and check the FCP marker.
  • Verify functionality on mobile and desktop: use device emulation in DevTools to confirm the menu opens, closes, and links work at both breakpoints.
  • Check search bot rendering: disable JavaScript in DevTools and confirm the top-level links and fallback links remain in the HTML.


Was this page helpful?