Menu Partials

One partial renders every menu you build in the CMS — a marketing top bar, a footer column, a documentation sidebar. You give it a menu name; it walks the tree and emits a nested <ul> with a stable set of class hooks and the ARIA a screen reader needs.

:::warning There is no {{> leed-menu }} The registered partial is {{> leed/menu/builder }}. leed-menu is the CSS class on the root <ul>, not a partial name, and older notes that show it as an include are wrong — the include silently renders nothing, because a partial that does not exist compiles to an empty string. :::

Calling it

The minimal call needs one thing, the name of the menu:

<nav>
    {{> leed/menu/builder menuName="top" }}
</nav>
ParameterRequiredTypeDefaultEffect
menuNameyesstring—The menu’s name key in _data/menu.json. Also becomes the leed-menu-<menuName> class and the utm_medium on every link.
menuClassnostring—Extra classes on the root <ul>, alongside leed-menu and leed-menu-<menuName>
submenuClassnostring—Extra classes on every nested <ul>
onClicknostring—A raw attribute string spliced into each <a>. Emitted with triple braces, so it is not escaped.
instancenostring—An analytics discriminator appended to utm_term, for when the same menu renders twice on one page
expandednobooleanfalseRender submenus expanded rather than collapsed

Two behaviors are worth stating outright.

menuName is the menu’s name, and an unresolved name fails silently. The partial opens with {{#with (lookup menu menuName)}}; if the lookup misses, the block renders nothing — no error, no empty <ul>, no build warning. This is the single most common cause of “my navigation disappeared”, and it usually means the menu was renamed in the CMS, or the template hard-codes a name where it should ask for one. Documentation templates never hard-code it: they call (docsMenuName "left"), (docsMenuName "top") or (docsMenuName "bottom") so the binding follows the page type’s configuration. Those helpers are at Documentation Navigation Helpers.

onClick is raw HTML. It is written into the anchor with {{{ onClick }}}, so whatever you pass lands in the tag verbatim — quotes and all. That is what makes onClick="@click=\"open = false\"" possible, and it is also why anything derived from user or CMS data must be escaped by you before it gets there.

The markup it emits

The output is the same shape wherever the menu appears, so there is one set of class names to style. Here is a two-level top menu, whitespace normalized:

<ul role="menu" aria-label="top - Navigation" class="leed-menu leed-menu-top" data-expanded="">
  <li role="menuitem" class="leed-menu__item" data-id="a1b2c3d4">
    <a href="/pricing/"
       data-reason="utm_campaign=leed&utm_source=menu&utm_medium=top&utm_term=a1b2c3d4&utm_content=Pricing"
       class="leed-menu__item--wrapper leed-menu__item--link">
      <span class="leed-menu__item--text">
        <span class="leed-menu__item--name">Pricing</span>
      </span>
    </a>
  </li>
  <li role="menuitem" aria-haspopup="menu" aria-controls="submenu-e5f6g7h8"
      class="leed-menu__item" data-id="e5f6g7h8" data-submenu-id="submenu-e5f6g7h8">
    <div class="leed-menu__item--wrapper leed-menu__item--no-link">
      <span class="leed-menu__item--text">
        <i class="leed-menu__item--icon fa-solid fa-cubes"></i>
        <span class="leed-menu__item--name">Product</span>
        <i class="leed-menu__item--toggle" aria-hidden="true"></i>
      </span>
    </div>
    <ul role="menu" class="leed-submenu leed-submenu--level-1" id="submenu-e5f6g7h8">
      <li role="menuitem" class="leed-submenu__item" data-id="i9j0k1l2">
        <a href="/product/analytics/" data-reason="…"
           class="leed-menu__item--wrapper leed-submenu__item--link">
          <span class="leed-menu__item--text">
            <span class="leed-menu__item--name">Analytics</span>
          </span>
        </a>
      </li>
    </ul>
  </li>
</ul>
<!-- Leed 'top' Menu generated by https://leed.ai -->

Note that the folder’s label is a <div>, not an <a> — a folder has no href, so there is nothing to link to. The wrapper class is on both shapes, which is what lets one rule style the clickable and non-clickable rows identically.

A mega-menu row, with every optional field populated

Called as {{> leed/menu/builder menuName="top" submenuClass="mega" expanded=true }}, with an item carrying an image, an icon, a description, a tooltip and an external target. Whitespace normalized.

<ul role="menu" aria-label="top - Navigation" class="leed-menu leed-menu-top" data-expanded="true">
  <li role="menuitem" aria-haspopup="menu" aria-controls="submenu-e5f6g7h8"
      data-expanded="true" aria-expanded="true"
      class="leed-menu__item" data-id="e5f6g7h8" data-submenu-id="submenu-e5f6g7h8">
    <div class="leed-menu__item--wrapper leed-menu__item--no-link">
      <span class="leed-menu__item--text">
        <i class="leed-menu__item--icon fa-solid fa-cubes"></i>
        <span class="leed-menu__item--name">Product</span>
        <i class="leed-menu__item--toggle" aria-hidden="true"></i>
      </span>
    </div>
    <ul role="menu" class="leed-submenu leed-submenu--level-1 mega"
        id="submenu-e5f6g7h8" aria-hidden="false">
      <li role="menuitem" class="leed-submenu__item" data-id="i9j0k1l2">
        <a href="https://status.example.com/" target="_blank" title="Opens our status page"
           data-reason="utm_campaign=leed&utm_source=menu&utm_medium=top&utm_term=i9j0k1l2&utm_content=Status"
           class="leed-menu__item--wrapper leed-submenu__item--link">
          <div class="leed-menu__item--image"><!-- the image helper's output, with responsive variants --></div>
          <span class="leed-menu__item--text">
            <span class="leed-menu__item--name">Status&nbsp;<i class="leed-menu__item--leave"></i></span>
          </span>
          <div class="leed-menu__item--description">Live uptime for every region</div>
        </a>
      </li>
    </ul>
  </li>
</ul>

expanded=true on the call reaches the root <ul> as data-expanded="true" and every item as data-expanded / aria-expanded, and each open submenu gains aria-hidden="false". The .leed-menu__item--leave icon appears because the item has a target; the image sits before the text span, and the description after it, in both the link and the no-link shapes.

Class hooks

ClassOn which elementWhen
leed-menuroot <ul>always
leed-menu-<menuName>root <ul>always — this is how you style one menu differently from another
leed-menu__item<li>top-level items
leed-submenu__item<li>items inside a submenu
leed-menu__item--wrapperthe <a> or <div>always, on both shapes
leed-menu__item--linkthe <a>top-level item with a renderable href
leed-submenu__item--linkthe <a>submenu item with a renderable href
leed-menu__item--no-linkthe <div>top-level item with no renderable href
leed-submenu__item--no-linkthe <div>submenu item with no renderable href
leed-menu__item--imagea <div> inside the wrapperthe item has an image
leed-menu__item--text<span>always — wraps icon, name and toggle
leed-menu__item--icon<i>the item has an icon
leed-menu__item--name<span>always — this is the visible label
leed-menu__item--leave<i> after the namethe item has a target
leed-menu__item--toggle<i>the item has a submenu
leed-menu__item--descriptiona <div> after the textthe item has a description
leed-submenunested <ul>always, on every submenu
leed-submenu--level-<n>nested <ul>n counts from 1 at the first level of nesting and is unbounded

Which cascade layer to put those rules in — and why an unlayered rule sometimes loses to Leed’s own — is covered at Cascade Layers and Overriding Leed.

The ARIA you get for free

The partial supplies role="menu" on every <ul>, aria-label="<menuName> - Navigation" on the root, role="menuitem" on every <li>, and — for items with children — aria-haspopup="menu", aria-controls="submenu-<menuItemId>", plus aria-expanded="true" and aria-hidden="false" when the item is expanded. Do not add your own; a wrapper that re-declares role="menu" or a second aria-label produces a nested-menu structure that assistive technology reads as two menus.

What each item field renders

These are the fields on a menu item that the templates actually read.

FieldRendered asNotes
namethe text inside .leed-menu__item--nameThe visible label. Emitted with triple braces, so inline HTML in a name is rendered, not escaped.
titletitle="…" on the <a>The hover tooltip. Not shown anywhere on screen otherwise.
hrefhref="…" on the <a>"" means folder. pageid:<id> is an internal page. Anything else is a literal href.
menuItemIddata-id on the <li>, and utm_term in data-reasonAlso builds id="submenu-<menuItemId>" on a submenu
targettarget="…" on the <a>A non-empty target also appends the .leed-menu__item--leave icon after the label
iconclasses on <i class="leed-menu__item--icon …">Space-separated CSS classes, for example fa-solid fa-book
iconStyleinline style on that same <i>Only meaningful together with icon
image.leed-menu__item--image wrapping the rendered image tagRendered through the image helper, so it gets responsive variants
description.leed-menu__item--description after the labelEmitted with triple braces. Used for mega-menu rows.
submenua nested <ul>, recursivelyA non-empty submenu is what makes an item a folder
disablednothingtrue removes the item — and its whole subtree — from the output
expandeddata-expanded / aria-expanded on the <li>Also inherited downward: an expanded folder expands its descendants

disabled: true does not gray an item out. It removes it, before anything else is evaluated, along with everything nested under it — which is what you want for staging a nav change, and not what you want if you were expecting a visual state.

Folders, leaves and unpublished targets

A menu can name a page that has not been published yet. That is deliberate: the CMS lets you build the whole navigation up front, and each entry lights up on the build after its page goes live. The rules that make it work are three shortcodes the templates call, and the net behavior is this:

Item shapeResult
Folder — has submenu items, href is ""<li> rendered; the label is a <div class="…--no-link">
Leaf pointing at a published page<li> rendered with a real <a>
Leaf pointing at an unpublished or deleted page<li> omitted entirely — no placeholder, no dead link
Folder whose own href points at an unpublished page<li> still rendered, label as a <div>, so its children stay reachable
disabled: trueomitted regardless of everything above
flowchart TD
    A[Menu item] --> B{disabled?}
    B -- yes --> Z[Item omitted entirely]
    B -- no --> C{has submenu items?}
    C -- yes --> R[Render the li]
    C -- no --> D{href empty?}
    D -- yes --> R
    D -- no --> E{"href starts with pageid:"}
    E -- no --> R
    E -- yes --> F{target rendered in this build?}
    F -- yes --> R
    F -- no --> Z
    R --> G{hrefIsRenderable href?}
    G -- yes --> H["a.leed-menu__item--link"]
    G -- no --> I["div.leed-menu__item--no-link"]

Three shortcodes produce that. menuItemShouldRender decides whether the <li> exists at all. hrefIsRenderable decides <a> versus <div> — note that it returns false for an empty href too, which is exactly why a folder’s label is never a link. pageIdExists is the primitive both are built on; no shipped template calls it, but it is available to yours. Full signatures are at Menu Safety Helpers.

pageid: at template time

Here is the fact you cannot learn anywhere else, and it changes how you write menu-aware templates.

item.href is still the literal string pageid:<id> while your template runs. Nothing resolves it during rendering. After Eleventy has written the page, a transform parses the built HTML, finds every anchor whose href starts with pageid:, and rewrites it to the target’s real path. Three consequences follow:

  • Do not parse or compare hrefs as paths in a template. {{#if (eq item.href page.url)}} never matches for an internal link, because one side is pageid:6e3d0570-… and the other is /docs/templates-and-layouts/leed-partial-index/. Compare page ids, or use the current-page state the sidebar’s own script maintains.
  • A pageid: value may carry a query string and a fragment, and the transform preserves both onto the resolved path. So a menu item — not only a body link — can deep-link to a heading: pageid:<id>#class-hooks resolves to /docs/…/menu-partials/#class-hooks. The authoring side is at Linking Between Docs Pages and Links and Internal Links.
  • The transform runs over the built DOM, so it applies to anything your template emits — including anchors inside a <template> element, which it descends into deliberately for Alpine’s x-if blocks. An anchor whose target did not render is not left broken: it is replaced by <span class="page-removed"> carrying the same inner text. That is why the previous/next controls on a documentation page can silently lose their link while the label survives — prev/next is computed from the menu tree without the rendered-page filter the menu itself applies.

Finding a <span class="page-removed"> in your built output is therefore always the same diagnosis: something linked to a page that did not render in that build.

Click tracking

Every rendered link carries a data-reason attribute:

utm_campaign=leed&utm_source=menu&utm_medium=<menuName>&utm_term=<menuItemId>[-<instance>]&utm_content=<name, tags stripped>

That is the channel Leed’s own click attribution reads, the same one behind Short Links and Attribution. You do not add it and you should not strip it.

instance exists for one situation and it is worth using when you hit it: the same menu rendered twice on one page — a desktop bar and a mobile drawer, or a top nav repeated in the footer. Without it both copies emit the identical utm_term, and the two are indistinguishable in analytics. Pass instance="mobile" on the second call and its terms become <menuItemId>-mobile.

A mobile header

The practical pattern is one builder call, one <ul>, and Alpine plus CSS deciding the shape. Nothing about the menu changes between the two modes; only the wrapper does.

<div class="w-full fixed top-0 z-40 h-24 menu-shrink min-w-[384px]"
    x-data="{open: false, isMobile: window.innerWidth < 768}"
    @resize.window="open = false; isMobile = window.innerWidth < 768">
    <nav id="menu-nav"
        class="grow whitespace-nowrap text-sm sm:text-base menu-shrink md:border-0 border-colors"
        :class="[
            isMobile ? 'menu-vertical w-full border-b py-6 px-4 sm:px-8' : 'menu-horizontal md:px-0',
            isMobile && !open ? 'hidden' : '']"
        x-show="!isMobile || open" x-transition>
        {{> leed/menu/builder menuName="top" }}
    </nav>
</div>

Alpine ships on every Leed page, so there is nothing to install. If you render this menu a second time in a drawer, pass instance on the second call.

For a documentation sidebar, do not build this yourself. The shell already renders the left menu with the collapse, filter and current-page behavior wired up — see Documentation Shell Partials, and Left Navigation Menu for the authoring side. If you have ejected the documentation header, render its navigation through this partial rather than hand-writing links, so unpublished targets keep disappearing correctly: Customizing the Documentation Header and Footer.

The recursive internals

Two more partials sit under leed/menu/, and they are internal — listed here so a stray class name or a stack trace is legible, not so you call them.

leed/menu/item renders one <li>. It applies the disabled and menuItemShouldRender filters, emits the ARIA, calls leed/menu/link for the label, and then recurses into item.submenu with submenuLevel incremented and expanded computed as (or ../expanded ../item.expanded) — which is how an expanded folder expands everything beneath it.

leed/menu/link renders the label. It calls hrefIsRenderable twice, once to open the <a> or <div> and once to close the matching tag, and it owns the data-reason attribute and every leed-menu__item--* class.

Both are called only from within the builder. Their names, parameters and markup are free to change between site-builder releases; the full status list for all fifty-four Leed partials is at the Leed Partial Index.

For a documentation set, the left menu is not only navigation — the folder names in it are the URL segments of the pages inside, which is why renaming a folder physically moves pages. That model is at Folders Set Your URLs, and the menus themselves, along with the one-level rule for documentation trees, at Menus and Navigation.

ESC