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>| Parameter | Required | Type | Default | Effect |
|---|---|---|---|---|
menuName | yes | string | — | The menu’s name key in _data/menu.json. Also becomes the leed-menu-<menuName> class and the utm_medium on every link. |
menuClass | no | string | — | Extra classes on the root <ul>, alongside leed-menu and leed-menu-<menuName> |
submenuClass | no | string | — | Extra classes on every nested <ul> |
onClick | no | string | — | A raw attribute string spliced into each <a>. Emitted with triple braces, so it is not escaped. |
instance | no | string | — | An analytics discriminator appended to utm_term, for when the same menu renders twice on one page |
expanded | no | boolean | false | Render 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 <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
| Class | On which element | When |
|---|---|---|
leed-menu | root <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--wrapper | the <a> or <div> | always, on both shapes |
leed-menu__item--link | the <a> | top-level item with a renderable href |
leed-submenu__item--link | the <a> | submenu item with a renderable href |
leed-menu__item--no-link | the <div> | top-level item with no renderable href |
leed-submenu__item--no-link | the <div> | submenu item with no renderable href |
leed-menu__item--image | a <div> inside the wrapper | the 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 name | the item has a target |
leed-menu__item--toggle | <i> | the item has a submenu |
leed-menu__item--description | a <div> after the text | the item has a description |
leed-submenu | nested <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.
| Field | Rendered as | Notes |
|---|---|---|
name | the text inside .leed-menu__item--name | The visible label. Emitted with triple braces, so inline HTML in a name is rendered, not escaped. |
title | title="…" on the <a> | The hover tooltip. Not shown anywhere on screen otherwise. |
href | href="…" on the <a> | "" means folder. pageid:<id> is an internal page. Anything else is a literal href. |
menuItemId | data-id on the <li>, and utm_term in data-reason | Also builds id="submenu-<menuItemId>" on a submenu |
target | target="…" on the <a> | A non-empty target also appends the .leed-menu__item--leave icon after the label |
icon | classes on <i class="leed-menu__item--icon …"> | Space-separated CSS classes, for example fa-solid fa-book |
iconStyle | inline style on that same <i> | Only meaningful together with icon |
image | .leed-menu__item--image wrapping the rendered image tag | Rendered through the image helper, so it gets responsive variants |
description | .leed-menu__item--description after the label | Emitted with triple braces. Used for mega-menu rows. |
submenu | a nested <ul>, recursively | A non-empty submenu is what makes an item a folder |
disabled | nothing | true removes the item — and its whole subtree — from the output |
expanded | data-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 shape | Result |
|---|---|
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: true | omitted 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 ispageid: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-hooksresolves 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’sx-ifblocks. 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.