Most overrides replace a page. These two replace part of a measured, positioned layout — the header’s height is what the page body’s top margin is set from — so the contract runs in both directions. Leed keeps a wrapper and a measurement; you own everything inside it.
Getting the file
leed site eject docs-header
leed site eject docs-footerSet expectations before you run either one. Ejecting the footer changes nothing visible: leed/docs/footer.hbs is only the wrapper and the gate, and leed/docs/docs-footer.hbs — the file you get — is exactly what was already rendering. Ejecting the header changes your header on the very next build, unedited, because the shipped source is a complete working example rather than a copy of the theme variant that was in place. Both behaviors follow from the eject registry’s design rule, explained at Overriding Leed Templates.
Ejected unedited, the example header is deliberately not a copy of the header you had. It composes four slots left to right:
flowchart LR
A["Logo"] --> B["menu-top navigation"] --> C["Compact search icon"] --> D["Get started CTA"]
That difference is the point: the file you now own is a worked example to edit, not a snapshot of the shipped header. Rebuild after ejecting and the page will visibly change.
The ownership boundary
flowchart TD
W["#docs-header-wrapper — Leed owns<br/>position, x-effect,<br/>--height-header, --height-toc"]
W --> B{"useCustomTemplate 'docsHeader'<br/>AND hasTier 'starter'"}
B -->|yes| Y["docs-header.hbs — you own<br/>all markup, layout, breakpoints"]
B -->|no| N["scroll indicator +<br/>#docs-header-container"]
N --> V["the theme's header variant<br/>top-search | compact"]
OUT["Supplied from outside the wrapper<br/>— yours either way"]
OUT --> M1["mini-bar: sidebar toggle"]
OUT --> M2["mini-bar: 'Jump to' TOC"]
OUT --> M3["Ctrl+K, bound on the search modal"]
| Element or behavior | Owned by | Why |
|---|---|---|
#docs-header-wrapper | Leed | each theme positions it and documentation.js measures it |
--height-header, --height-toc | Leed | published by the wrapper’s x-effect; the first is the body’s top margin |
| The scroll indicator | Leed’s default branch | it is inside the replaced region, so an override drops it |
#docs-header-container | Leed’s default branch | likewise — your file replaces this element, it does not sit inside it |
| Your header’s markup, layout and breakpoints | you | from inside the wrapper down |
| The sidebar toggle | Leed | lives in leed/docs/mini-bar, below the header |
| The mobile “Jump to” TOC | Leed | same partial, same reason |
Ctrl+K | Leed | bound on the search modal, not on the trigger |
| The search modal | Leed | rendered once by the shell |
| The search trigger | you | include leed/docs/document-search if you want one |
| Navigation markup | you | but the structure should come from the menu builder |
#docs-footer-wrapper | Leed | the footer’s own wrapper and gate |
| The footer’s contents | you, on Starter and up | the whole of docs-footer.hbs |
| The “Powered by Leed” badge | Leed, below Starter | it is the inverse of the same gate |
What Leed keeps
Four things are yours to cooperate with rather than to own.
#docs-header-wrapper. Each layout theme positions it — fixed, top-0, its own z-index and backdrop — and documentation.js measures it. Your file renders inside it.
--height-header and --height-toc. The wrapper’s x-effect writes both onto the document element. --height-header is the top margin of the page body, so a header whose height your CSS changes still lays the page out correctly, at whatever height it settles on.
The sidebar toggle and the mobile “Jump to” TOC. Both live in leed/docs/mini-bar, which sits below the header, so you get them regardless of what your header does.
Ctrl+K. It is bound on the search modal, not on the trigger button. The shortcut therefore keeps working whether or not your header includes a search control at all.
The Alpine state you can read
The wrapper carries the documentationHeaderComponent scope, so your markup reads its state directly without declaring an x-data of its own.
| Name | Type | Set by | Typical use |
|---|---|---|---|
headerSearchShow | boolean | the search trigger; Ctrl+K; Escape | opening and closing the search modal |
topMenuOpen | boolean | your own toggle button | a mobile navigation drawer |
isSmallerThanDesktop | boolean | the component’s resize handler | showing a drawer instead of a bar |
headerHeight | number | measured on resize | rarely needed directly — the CSS variable is the interface |
tocHeight | number | measured on resize | likewise |
<button type="button" @click="topMenuOpen = !topMenuOpen" aria-expanded="false">
<span class="sr-only" x-text="topMenuOpen ? 'Close menu' : 'Open menu'"></span>
</button>
<nav x-show="!isSmallerThanDesktop || topMenuOpen">
{{> leed/menu/builder menuName=(docsMenuName "top") }}
</nav>Keeping the search control
{{> leed/docs/document-search }}
{{> leed/docs/document-search compact=true }} The compact variant’s style hook is #document-search-container.search-compact. Two rules, both consequences of the same fact — the trigger sets Alpine state that the docs shell owns, and the shell is what renders the modal it opens:
- It works inside the documentation layout only.
- On a marketing page there is no such scope and no search index, so the control renders and does nothing. If you share one header partial across both surfaces, gate the include on a parameter you pass rather than on anything the template can detect.
Rendering navigation
Use {{> leed/menu/builder }} and let the CMS keep owning the structure; do not hand-write links, or a menu edit in the CMS will silently stop reaching your header. The shipped example takes the approach worth copying: one menu builder call producing one <ul>, which CSS then shapes into both the desktop bar and the mobile drawer, so the two cannot drift apart.
Read the configured menu rather than hard-coding a name:
{{> leed/menu/builder menuName=(docsMenuName "top") menuClass="menu-top" }}The partial itself, its parameters and what it emits for a folder versus a leaf are documented at Menu Partials.
menus.top and menus.bottom hold a menu id, not a menu name
This indirection is what makes an ejected header work, and getting it wrong fails silently. The three slots in documentationConfiguration.menus each store a menu id. The top-level keys of _data/menu.json are menu names. docsMenuName bridges the two by scanning for the entry whose menuId matches. An unresolved slot renders nothing at all — the menu builder’s {{#with (lookup menu menuName)}} simply produces no output, with no error and no fallback.
| Slot | Holds | Renders where | Classifies the menu as a docs menu? | An unresolved value looks like |
|---|---|---|---|---|
top | a menuId | the header navigation | no | nothing renders, silently |
left | a menuId | the sidebar | yes — this is the slot that triggers folder-path derivation, href stripping and the no-children rule | an empty sidebar |
bottom | a menuId | the footer columns | no | the whole logo-and-columns block disappears; the social row stays |
The asymmetry in the middle column is the useful part. Only the left slot classifies a menu as a documentation menu: the check tests slots.left === menuId and nothing else, and it is that boolean alone which gates the folder-name-to-URL-segment derivation, the stripping of hrefs from folders, and the rejection of a page item with children. So an existing site header or footer menu can be reused verbatim as menus.top or menus.bottom — three levels deep, external hrefs and all — with nothing reclassified and no page path moved. That is what makes one navigation across marketing and documentation possible.
Reusing your existing site header and footer
This is the route most sites with a marketing site actually take: point menus.top at the site’s existing menu, and if the default chrome is not enough, eject docs-header.hbs and include your own site header partial from it. It has five sharp edges, and every one of them fails quietly.
| Hazard | Symptom | Fix |
|---|---|---|
Two x-effects writing --height-header | content sits under the header, or jumps on resize | strip the measurement from the inner partial inside the docs shell |
position: fixed inside the fixed wrapper | the measurement breaks outright | the wrapper positions; your partial does not |
Scroll indicator included with no scrollBarId | nothing renders, and it looks like a CSS problem | pass the id, or leave the indicator out |
A centered max-w-* container | the header is visibly misaligned with the sidebar and TOC rails | a wide modifier applied only inside the docs |
| The theme’s anchor rule reaching your chrome | reused header and footer links come out in the docs body-link color | update the CLI; if you must patch it, an unlayered stylesheet, never !important |
Two Alpine x-effects racing for --height-header
If your site header partial is itself an x-data="headerComponent(…)" wrapper whose own x-effect writes --height-header and --height-toc onto document.documentElement, nesting it inside #docs-header-wrapper — which carries the identical effect from documentationHeaderComponent — puts two effects on the property that is the body’s top margin. The two race, and which wins varies by load. Let the wrapper own the measurement and take it out of the inner partial when it renders inside the docs shell.
fixed inside fixed
A marketing header that is fixed top-0 z-40 nested inside a wrapper that is already fixed with a much higher z-index does not merely look wrong — it takes the header out of flow inside the element being measured, so the measurement is meaningless. Same rule as the section below, stated for the reuse case.
The scroll indicator wants an id
leed/components/scroll-status-indicator-horizontal renders nothing without scrollBarId. A shared site header that includes it without passing one produces no reading-progress bar and no error. Leed’s own documentation chrome has the same gap today, which is why no documentation page currently shows a progress bar; if you want one in your header, pass the id explicitly. See Head and Component Partials.
Edge-to-edge, not max-w-6xl
The documentation header must run the full viewport width, because the sidebar is flush left and the table of contents is flush right underneath it. A marketing header centered at max-w-6xl mx-auto leaves the docs chrome visibly misaligned with both rails. The shipped example handles this with a modifier class applied only when a wide parameter is passed — a shape worth copying, since it keeps one partial correct on both surfaces:
<header class="site-header{{#if wide}} site-header--wide{{/if}}">The anchor-color bleed
The one that reads as a CSS mystery. Every documentation color theme ends by applying the shared docs-theme-defaults utility, which sets the body-link color on anchors. In older builder releases that rule was written against the document root, so it matched every anchor on the page — your reused header menu, your footer columns, your social icons — and because it compiles into Tailwind’s utilities layer, no layered rule of yours could outrank it at any specificity. The current builder scopes it to #leed-docs-copy, the section that wraps the H1 and the prose and nothing else, so chrome anchors are left alone.
If your reused header’s links are coming out in the docs link color, update the CLI first. Do not reach for !important: the correct escape has always been an unlayered stylesheet scoped to .leed-documentation and imported after the theme. Full treatment at Cascade Layers and Overriding Leed, and keep chrome colors on the --site-* tokens so the header follows the reader’s color scheme — Site Token Contract and Dark Mode.
The header button
documentationConfiguration.header.button is a single optional {text, href} object, and the default header renders exactly one <a class="docs-button-primary"> from it. There is no target, no secondary button and no array: a marketing header’s usual Log in plus Sign up pair cannot both survive on the default route. Pick the primary one, or eject and render both yourself.
Two gotchas:
- The template’s
{{#if}}tests the object, not its fields. A"button": {}saved with both fields blank emits an anchor with an emptyhrefand no label. - On an ejected header the slot is inert unless your template reads it. The CMS field keeps accepting edits that change nothing, which is a confusing failure to debug from the CMS side. If you want it to keep working, read it:
{{#if documentationConfiguration.header.button}}
<a class="docs-button-primary" href="{{ documentationConfiguration.header.button.href }}">
{{ documentationConfiguration.header.button.text }}
</a>
{{/if}}The CMS half of this — the button, the light and dark logos, the footer menu — is at Documentation Header, Footer and Logos.
Do not position your own header
/* Marketing: the header positions itself. */
.site-header { position: fixed; top: 0; z-index: 40; }
/* Documentation: the wrapper already did. */
#docs-header-wrapper .site-header { position: static; z-index: auto; }The footer
leed/docs/footer is only the wrapper #docs-footer-wrapper and the gate. leed/docs/docs-footer is what renders — which is why the ejected footer is byte-identical. It contains the bottom menu via docsMenuName "bottom", the social icons row, and the copyright line. Two structural facts to know before you start editing:
menus.bottomgates the entire logo-plus-columns block. Leave the slot unset and that whole region disappears silently, which reads as a broken footer rather than a missing setting.- The social icons row is rendered unconditionally, outside that guard. It comes from the company’s
externalAccounts, not from anything indocumentationConfiguration, so it appears even when the columns do not.
The charlie layout renders the footer at page level rather than inside the content column, so footer CSS targets #docs-footer-wrapper and #docs-footer, already capped at max-w-5xl by that layout. hideLogo is a parameter the docs pagination passes when it renders the footer inline beneath the prev/next links.
A minimal starting file
- docs-header.hbs
- docs-footer.hbs
<header class="site-header{{#if wide}} site-header--wide{{/if}}">
<div class="nav">
<a href="/" class="logo" aria-label="{{ siteTitle }}, home">
<div class="logo-header"></div>
</a>
<button type="button" @click="topMenuOpen = !topMenuOpen"
aria-label="Open menu" :aria-expanded="topMenuOpen">
<i class="fa-light fa-bars" aria-hidden="true"></i>
</button>
<div class="nav-right" x-show="!isSmallerThanDesktop || topMenuOpen">
<nav aria-label="Main">
{{> leed/menu/builder menuName=(docsMenuName "top") menuClass="menu-top" }}
</nav>
{{> leed/docs/document-search compact=true }}
{{#if documentationConfiguration.header.button}}
<a class="btn btn-primary" href="{{ documentationConfiguration.header.button.href }}">
{{ documentationConfiguration.header.button.text }}
</a>
{{/if}}
</div>
</div>
</header>
<footer id="docs-footer" aria-labelledby="footer-heading">
<span class="sr-only">Footer</span>
{{#if documentationConfiguration.menus.bottom }}
<div class="docs-footer-content">
{{#isFalsey hideLogo}}
<a href="/"><div class="logo-footer"></div></a>
{{/isFalsey}}
<div class="docs-footer-menu-container">
{{> leed/menu/builder menuName=(docsMenuName "bottom") }}
</div>
</div>
{{/if}}
<div class="docs-footer-bottom-container">
{{> leed/docs/social-icons }}
<span class="copyright-text">© {{ year }} {{ siteTitle }}.</span>
</div>
</footer>The full shipped docs-header.hbs, as leed site eject writes it
<header id="menu-container" class="site-header{{#if wide}} site-header--wide{{/if}}">
{{#if scrollBarId}}
<div class="reading-progress" aria-hidden="true">
<div class="reading-progress-bar" data-reading-progress="{{ scrollBarId }}"></div>
</div>
{{/if}}
<div class="nav">
<a href="/" class="logo" aria-label="{{ siteTitle }}, home">
{{#isFalsey hideLogo}}
<span class="sr-only">{{ siteTitle }}</span>
<div class="logo-header"></div>
{{/isFalsey}}
</a>
<button type="button" class="menu-btn" data-menu-toggle aria-label="Open menu"
aria-expanded="false" aria-controls="site-nav">
<i class="fa-light fa-bars text-xl" aria-hidden="true"></i>
</button>
<div class="nav-right" id="site-nav" data-menu>
<nav aria-label="Main">
{{> leed/menu/builder menuName="header" menuClass="menu-top" submenuClass="menu-top-submenu" }}
</nav>
{{#if showDocsSearch}}
{{> leed/docs/document-search compact=true }}
{{/if}}
{{#if ctaHref}}
<a href="{{ ctaHref }}" class="btn btn-primary">{{ ctaLabel }}</a>
{{else}}
<a href="/" class="btn btn-primary">Get started</a>
{{/if}}
</div>
</div>
</header>Note the six parameters it accepts — wide, scrollBarId, hideLogo, showDocsSearch, ctaHref and ctaLabel — and that the chrome passes none of them. An ejected header therefore runs with all six undefined: no reading-progress bar, no search control, the logo shown, and the default Get started button pointing at /. That is the single biggest difference between the two screenshots above, and the first thing to change once the file is yours.
Checking your work
Rebuild and walk this list. Every item is something the wrapper contract is protecting, and every failure is quiet.
- The page body starts below the header, and still does after a resize — that is
--height-headertracking correctly. - The mobile drawer opens and closes below
lg. Ctrl+Kstill opens search, whether or not your header has a trigger.- Breadcrumbs, the table of contents and the prev/next links are unaffected.
- The footer’s columns appear (or you know why they do not — check
menus.bottom). - If you share the partial with your marketing site, both surfaces still look right: the header positions itself on marketing and not in the docs.
What sits above and below your header — the shell, the sidebar, the mini bar and the article — is mapped at Documentation Shell Partials. The command that writes the file, with its flags and failure messages, is at leed site eject, and the other four override slots, including the free ones, are at Overriding Leed Templates.