Header — start here
Install as site header
The one to use for a header. It carries every file inline with its destination path, and tells the destination agent to inventory the existing routes and CSS before replacing anything — the parts that go wrong when a header meets a real layout.
Read it first
Install "Sticky mega menu header" from my Astro Component Library as this project's site header.
This is a header, not a page section. It replaces something that already exists, over routes that already exist, under CSS that already exists. Work through the steps in order and report as you go.
--- WHAT THIS HEADER IS ---
Component: Sticky mega menu header
Library page: https://astro.baysixmedia.com/components/sticky-mega-menu-header/
Navigation mode: mega-menu — wide multi-column panels that open from their triggers
Position mode: sticky
Desktop dropdowns: yes
Separate mobile menu: yes
Locks body scroll while open: yes
Astro client navigation: supported — it re-initialises on astro:page-load and detaches its listeners before each swap
Required files: 1
Dependencies: none — no npm packages, no CSS framework, no UI framework
Assets: none
Prop interface, verbatim from the component:
interface Props {
/** Wordmark text beside the brand mark. `""` renders the mark alone. */
brandName?: string;
/** Where the brand links to. */
brandHref?: string;
/** Accessible name for the primary `<nav>`. */
navLabel?: string;
/** Accessible name for the small-screen menu button. */
menuLabel?: string;
/** The navigation items. */
items?: NavItem[];
/** Secondary action at the right. `""` hides it. */
loginLabel?: string;
loginHref?: string;
/** Primary action at the right. `""` hides it. */
ctaLabel?: string;
ctaHref?: string;
/** Marks the matching link `aria-current="page"`. */
currentPath?: string;
/** Header height. The reference holds this constant on scroll. */
height?: string;
/** Caps the header's inner row. */
maxWidth?: string;
/** Page ground the header sits on. Also the colour it fades in when scrolled. */
background?: string;
/** Mega panels and the mobile sheet. */
surface?: string;
/** Primary text. */
ink?: string;
/** Descriptions and group headings. */
muted?: string;
/** Hairlines on panels and the sheet. */
border?: string;
/** Primary button fill. */
ctaBg?: string;
/** Primary button text. */
ctaInk?: string;
/** Focus ring and the current-page marker. */
accent?: string;
/** Backdrop blur once scrolled. `0px` turns the glass off. */
blur?: string;
/** Stacking order. It floats over the page, so it has to win. */
zIndex?: number;
/** Added to the root element. */
class?: string;
/** Root element id. Also seeds the panel and sheet ids. */
id?: string;
}
Route-data example (src/components/StickyMegaMenuHeader.nav-data.ts) — replace every route in it with real ones from this project:
/**
* StickyMegaMenuHeader — navigation data, worked example.
*
* The component ships no menu of its own (Step 14): pass `items` or the header
* renders no navigation. This file is a worked example of that data. What it is for is the thing that actually goes
* wrong when a header is installed somewhere real: the destination project has
* its own routes, and someone has to map them into this shape. Doing that in a
* data file rather than inline in a layout means the routes can be read,
* reviewed and changed without touching markup.
*
* Copy it next to the component, edit every label and href to match the
* destination's real routes, and pass it in:
*
* ---
* import Header from "../components/StickyMegaMenuHeader.astro";
* import { primaryNav } from "../components/StickyMegaMenuHeader.nav-data";
* ---
* <Header items={primaryNav} currentPath={Astro.url.pathname} />
*
* The types come from the component itself, so this file cannot drift from
* what the component accepts: rename a field there and `astro check` fails
* here.
*/
import type { NavItem } from "./StickyMegaMenuHeader.astro";
/**
* Every route in the example tree is a placeholder. Replace them with routes
* that exist in the destination project — a header pointing at four 404s is
* worse than no header, because it looks finished.
*
* Shape rules the component enforces:
* - an item has `href` OR `groups`, never both;
* - an item with `groups` renders as a mega-panel trigger (a real `<button>`);
* - an item with `href` renders as a plain link;
* - `heading` on a group may be `""` to render a column with no heading.
*/
export const primaryNav: NavItem[] = [
{
label: "Product",
groups: [
{
heading: "By need",
links: [
{ label: "Overview", href: "/product", description: "What it does, in one page." },
{ label: "Integrations", href: "/product/integrations", description: "What it talks to." },
{ label: "Security", href: "/product/security", description: "How access is handled." },
],
},
{
heading: "By team",
links: [
{ label: "Engineering", href: "/teams/engineering" },
{ label: "Design", href: "/teams/design" },
{ label: "Operations", href: "/teams/operations" },
],
},
],
},
{
label: "Resources",
groups: [
{
heading: "Learn",
links: [
{ label: "Guides", href: "/guides", description: "Short, practical walkthroughs." },
{ label: "Changelog", href: "/changelog", description: "What shipped, and when." },
],
},
],
},
// A plain destination: no `groups`, so no panel and no trigger button.
{ label: "Pricing", href: "/pricing" },
{ label: "Contact", href: "/contact" },
];
Known integration notes for this header:
- Sticky rather than fixed: it holds its own space, so the page needs no height reserved for it. Put it as the first child of <body>, before <main>, not inside a wrapper that establishes a containing block.
- An ancestor with overflow other than visible — or a transform, filter or contain — turns position: sticky into a no-op. That is the single most common reason it stops sticking in a destination layout.
- The mobile sheet is position: fixed below the bar and locks body scroll while open. Anything else on the page that writes to document.body.style.overflow will fight it.
- Mega panels are full-bleed and escape the header's own max width, so a layout that clips horizontal overflow above the header will clip them.
- Pass currentPath={Astro.url.pathname} to get aria-current on the active link; it is not inferred.
- The demo tree is eight items deep in places. A destination with three routes should pass a smaller items array rather than padding it out.
--- EXPECTED BEHAVIOUR ---
Desktop: triggers are real <button> elements carrying aria-expanded and aria-controls; wide multi-column panels that open from their triggers; one panel open at a time; Escape closes and returns focus to its trigger; a click outside closes; links inside a closed panel are out of the tab order.
Mobile: a real toggle button with aria-expanded and aria-controls opens the small-screen menu, and the page behind it stops scrolling while it is open; crossing back to the desktop breakpoint while it is open resets it.
Accessibility: a single landmark <nav> with an accessible name, aria-current on the link for the current route, visible focus on every control, 44x44px minimum targets, full prefers-reduced-motion support, and a working no-JavaScript fallback. None of this may be removed while adapting the design.
--- STEPS ---
1. Inspect this project before editing anything. Find its Astro version, whether it uses <ClientRouter /> or view transitions, its global stylesheet, and its existing header. Report what you found before changing a file.
2. Find the real shared layout — the one every page actually renders through — and the current header inside it. Do not assume src/layouts/Layout.astro.
3. Inventory every current navigation route: read the existing header, src/pages/, and any route or nav-data file. List every label and href before touching anything.
4. Map those real routes into this component's navigation structure. Use the destination's own routes and labels. Do not ship the demo tree, and do not invent routes to fill out a panel that looks empty.
5. Preserve the destination's branding and approved copy: its brand name, wordmark, logo mark, button labels and tone. Adapt the component's colour and type props to the destination's tokens.
6. Identify global CSS in this project that targets header, nav, a, button, ul or body, and anything that sets overflow or transform on an ancestor of the header. Those are what break an installed header. Report the collisions and how you resolved each one.
7. Build the replacement alongside the existing header first. Remove the old one only once the new one renders and works on every route.
8. Remove the old header's JavaScript and CSS once it is gone — menu toggles, scroll listeners, body-scroll locks, media-query handlers and their stylesheets. Leaving them causes double body locks and phantom listeners.
9. Make sure exactly one header renders. Check for a second one in a nested layout, in an individual page, or left behind in the old markup.
10. Wire up positioning. It is position: sticky, so it holds its own space in the flow and no height needs reserving. It must not be placed inside an ancestor with overflow other than visible, or with a transform, filter or contain — any of those silently stop sticky working.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
12. Preserve the skip link. If the project has one it must still be the first focusable element and must still land on the main content; if a sticky header would cover the target, add scroll-margin-top to it. If the project has no skip link, add one.
13. Leave analytics, meta tags, SEO, structured data and unrelated scripts in the layout exactly as they are. Removing a header is not a reason to touch anything else in <head>.
14. Make it work on a normal page load and, if this project uses client-side routing, across client navigation too: state reset between pages, no listeners accumulating, no body scroll lock surviving a navigation.
15. Visit every real route in this project and confirm the header renders, the correct link is marked current, and every href resolves. No 404s.
16. Test the whole site at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, no clipped panels, keyboard operation throughout, and the header never covering focused content.
--- FILES ---
Write each block below to the path in its header. Only the file marked (required) is needed to render; the supporting files are documentation and worked examples.
===== FILE (required): src/components/StickyMegaMenuHeader.astro =====
---
/**
* Sticky mega menu header
*
* A marketing site header that sticks to the top of the viewport, turns from
* transparent to frosted glass the moment the page scrolls, opens wide mega
* panels from its nav items, and collapses to a full-screen sheet below the
* desktop breakpoint.
*
* Self-contained: no imports, no packages, no global stylesheet, no images.
* Every colour and measurement is a `--mh-*` custom property on the root.
*/
export interface MegaLink {
label: string;
href: string;
/** Second line under the label inside a mega panel. */
description?: string;
}
export interface MegaGroup {
/** Small heading above a column of links. `""` renders no heading. */
heading?: string;
links: MegaLink[];
}
export interface NavItem {
label: string;
/** A plain destination. Give this OR `groups`, not both. */
href?: string;
/** Turns the item into a mega-panel trigger. */
groups?: MegaGroup[];
}
interface Props {
/** Wordmark text beside the brand mark. `""` renders the mark alone. */
brandName?: string;
/** Where the brand links to. */
brandHref?: string;
/** Accessible name for the primary `<nav>`. */
navLabel?: string;
/** Accessible name for the small-screen menu button. */
menuLabel?: string;
/** The navigation items. */
items?: NavItem[];
/** Secondary action at the right. `""` hides it. */
loginLabel?: string;
loginHref?: string;
/** Primary action at the right. `""` hides it. */
ctaLabel?: string;
ctaHref?: string;
/** Marks the matching link `aria-current="page"`. */
currentPath?: string;
/** Header height. The reference holds this constant on scroll. */
height?: string;
/** Caps the header's inner row. */
maxWidth?: string;
/** Page ground the header sits on. Also the colour it fades in when scrolled. */
background?: string;
/** Mega panels and the mobile sheet. */
surface?: string;
/** Primary text. */
ink?: string;
/** Descriptions and group headings. */
muted?: string;
/** Hairlines on panels and the sheet. */
border?: string;
/** Primary button fill. */
ctaBg?: string;
/** Primary button text. */
ctaInk?: string;
/** Focus ring and the current-page marker. */
accent?: string;
/** Backdrop blur once scrolled. `0px` turns the glass off. */
blur?: string;
/** Stacking order. It floats over the page, so it has to win. */
zIndex?: number;
/** Added to the root element. */
class?: string;
/** Root element id. Also seeds the panel and sheet ids. */
id?: string;
}
const {
// No invented brand, navigation tree or actions: the site supplies its own.
brandName = "",
brandHref = "/",
navLabel = "Primary",
menuLabel = "Toggle navigation menu",
items = [],
loginLabel = "",
loginHref,
ctaLabel = "",
ctaHref,
currentPath,
height = "64px",
maxWidth = "1280px",
background = "#fcfbf8",
surface = "#ffffff",
ink = "#1a1a1a",
muted = "#6b6b6b",
border = "#e4e2dc",
ctaBg = "rgb(0 0 0 / 88%)",
ctaInk = "#fbfbfb",
accent = "#3b5bdb",
blur = "24px",
zIndex = 50,
class: className,
id,
} = Astro.props;
// One id prefix per instance, so several headers can share a page without
// their triggers and panels pointing at each other's elements.
const uid = id ?? `mh-${Math.random().toString(36).slice(2, 9)}`;
const sheetId = `${uid}-sheet`;
const isCurrent = (href?: string) => Boolean(href && currentPath && href === currentPath);
// Each block renders only when it has something in it. The burger and the
// sheet exist only when there is navigation or an action to put in them; with
// no brand either, there is no header to draw at all.
const hasLogin = Boolean(loginLabel && loginHref);
const hasCta = Boolean(ctaLabel && ctaHref);
const hasActions = hasLogin || hasCta;
const hasMenu = items.length > 0 || hasActions;
const hasContent = hasMenu || Boolean(brandName);
const style = [
`--mh-h:${height}`,
`--mh-max:${maxWidth}`,
`--mh-bg:${background}`,
`--mh-surface:${surface}`,
`--mh-ink:${ink}`,
`--mh-muted:${muted}`,
`--mh-border:${border}`,
`--mh-cta-bg:${ctaBg}`,
`--mh-cta-ink:${ctaInk}`,
`--mh-accent:${accent}`,
`--mh-blur:${blur}`,
`--mh-z:${zIndex}`,
].join(";");
---
{hasContent && (
<header class:list={["header", className]} id={id} style={style} data-header-root data-mh-root>
{/*
Sticky and the scroll state both live here. In the reference they sit on
an ancestor two levels above the matched element; folding them in is what
makes this one portable file rather than a fragment that needs a wrapper.
*/}
<div class="bar" data-mh-bar>
<div class="inner">
<a class="brand" href={brandHref}>
<svg class="mark" viewBox="0 0 28 28" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="22" height="22" rx="7"></rect>
<path d="M10 18.5V9.5l8 9v-9"></path>
</svg>
{brandName && <span class="brand-name">{brandName}</span>}
{!brandName && <span class="sr-only">Home</span>}
</a>
{items.length > 0 && (
<nav class="desk" aria-label={navLabel}>
<ul class="row">
{
items.map((item, i) => {
const panelId = `${uid}-panel-${i}`;
return item.groups && item.groups.length > 0 ? (
<li class="item" data-mh-item>
<button
type="button"
class="trigger"
data-mh-trigger
aria-expanded="false"
aria-controls={panelId}
aria-haspopup="true"
>
{item.label}
<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6.5 8 10.5 12 6.5"></path>
</svg>
</button>
<div class="panel" id={panelId} data-mh-panel>
<div class="card">
{item.groups.map((group) => (
<div class="group">
{group.heading && <p class="group-head">{group.heading}</p>}
<ul class="group-list">
{group.links.map((link) => (
<li>
<a
class="cell"
href={link.href}
aria-current={isCurrent(link.href) ? "page" : null}
>
<span class="cell-title">{link.label}</span>
{link.description && (
<span class="cell-desc">{link.description}</span>
)}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</li>
) : (
<li class="item">
<a
class="link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : null}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{hasActions && (
<div class="actions">
{loginLabel && loginHref && <a class="ghost" href={loginHref}>{loginLabel}</a>}
{ctaLabel && ctaHref && <a class="cta" href={ctaHref}>{ctaLabel}</a>}
</div>
)}
{hasMenu && (
<button
type="button"
class="burger"
data-mh-burger
aria-expanded="false"
aria-controls={sheetId}
aria-label={menuLabel}
>
<svg class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<line class="bar-a" x1="4" y1="9" x2="20" y2="9"></line>
<line class="bar-b" x1="4" y1="15" x2="20" y2="15"></line>
</svg>
</button>
)}
</div>
</div>
{/*
The small-screen counterpart. In the reference this is a sibling element
in the same header slot, shown by the inverse of the same breakpoint, and
it carries the same primary navigation — which is why it belongs in the
same component rather than being treated as an unrelated control.
*/}
{hasMenu && (
<div class="sheet" id={sheetId} data-mh-sheet>
<nav class="sheet-nav" aria-label={navLabel}>
{
items.map((item) =>
item.groups && item.groups.length > 0 ? (
<details class="sheet-group" name={`${uid}-sheet-acc`}>
<summary class="sheet-summary">
{item.label}
<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6.5 8 10.5 12 6.5"></path>
</svg>
</summary>
<ul class="sheet-sub">
{item.groups.flatMap((g) => g.links).map((link) => (
<li>
<a
class="sheet-link is-sub"
href={link.href}
aria-current={isCurrent(link.href) ? "page" : null}
>
{link.label}
</a>
</li>
))}
</ul>
</details>
) : (
<a
class="sheet-link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : null}
>
{item.label}
</a>
),
)
}
{hasActions && (
<div class="sheet-actions">
{loginLabel && loginHref && <a class="ghost wide" href={loginHref}>{loginLabel}</a>}
{ctaLabel && ctaHref && <a class="cta wide" href={ctaHref}>{ctaLabel}</a>}
</div>
)}
</nav>
</div>
)}
</header>
)}
<style>
/*
* Sticky lives on the root, not on the bar inside it. A sticky element can
* only travel within its own parent's box — and this root is exactly as
* tall as the bar, so sticking the bar to the root would give it nowhere to
* go and it would scroll away with the page. On the root, the containing
* block is whatever the host puts it in, which is the whole page.
*/
.header {
--mh-pad: 16px;
position: sticky;
top: 0;
z-index: var(--mh-z);
box-sizing: border-box;
color: var(--mh-ink);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-size: 16px;
line-height: 1.5;
}
/* Set rather than inherited: a destination site's global rules must not be
able to change the header's height or push a row onto a second line. */
.header *,
.header *::before,
.header *::after {
box-sizing: border-box;
}
.header ul {
margin: 0;
padding: 0;
list-style: none;
}
.header p {
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- the sticky bar ---------------------------------------------------- */
.bar {
height: var(--mh-h);
background: transparent;
/* Only these two animate in the reference; the blur arrives instantly. */
transition:
background-color 300ms cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Set from the first pixel of scroll, exactly as the reference flips it. */
[data-mh-scrolled] .bar {
background: color-mix(in srgb, var(--mh-bg) 75%, transparent);
-webkit-backdrop-filter: blur(var(--mh-blur));
backdrop-filter: blur(var(--mh-blur));
box-shadow: 0 1px 2px rgb(0 0 0 / 4%), 0 8px 24px -12px rgb(0 0 0 / 12%);
}
.inner {
display: flex;
align-items: center;
gap: 16px;
height: 100%;
max-width: var(--mh-max);
margin-inline: auto;
padding-inline: var(--mh-pad);
}
/* --- brand -------------------------------------------------------------- */
/*
* Below the desktop breakpoint the brand may shrink and the name wraps (two
* short lines fit the 64px bar), so a long business name never pushes the
* menu button off-screen. Desktop keeps the single line (see 1024px).
*/
.brand {
display: inline-flex;
flex: 0 1 auto;
min-width: 0;
align-items: center;
gap: 8px;
min-height: 44px;
color: var(--mh-ink);
text-decoration: none;
}
.mark {
flex: none;
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.brand-name {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}
/* --- desktop nav --------------------------------------------------------- */
.desk {
display: none;
}
.row {
display: flex;
align-items: center;
gap: 2px;
}
.item {
position: relative;
display: flex;
align-items: center;
}
.link,
.trigger {
display: inline-flex;
align-items: center;
gap: 2px;
min-height: 44px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: none;
color: var(--mh-ink);
font: inherit;
font-size: 0.9375rem;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.link:hover,
.trigger:hover,
[data-mh-open] > .trigger {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.link[aria-current="page"] {
color: var(--mh-accent);
font-weight: 500;
}
.chev {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-mh-open] > .trigger .chev {
transform: rotate(180deg);
}
/* --- mega panel ----------------------------------------------------------- */
/*
* Hidden by default and revealed on hover or focus-within, so the panels
* work with no JavaScript at all. Once the script runs it takes over with
* `data-mh-open`, which is what makes Escape and outside-click possible.
*/
/*
* `top: 100%` with the visual offset supplied by padding, not by a gap.
* A real gap between the trigger and the panel is a dead zone: the pointer
* crosses it, leaves the item, and the panel closes underneath. Padding
* keeps the panel's hit box flush against the trigger, so travelling down
* into it never leaves the item at all.
*/
.panel {
position: absolute;
top: 100%;
left: -8px;
z-index: 1;
padding-top: 6px;
visibility: hidden;
opacity: 0;
transform: translateY(-4px);
transition:
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.4, 0, 0.2, 1),
visibility 150ms;
}
.item:hover > .panel,
.item:focus-within > .panel,
.item[data-mh-open] > .panel {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
/* With the script running, hover and focus alone no longer open a panel —
the attribute is the single source of truth, so Escape actually closes. */
[data-mh-js] .item:hover > .panel,
[data-mh-js] .item:focus-within > .panel {
visibility: hidden;
opacity: 0;
transform: translateY(-4px);
}
[data-mh-js] .item[data-mh-open] > .panel {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
.card {
display: flex;
gap: 28px;
padding: 16px;
border: 1px solid var(--mh-border);
border-radius: 14px;
background: var(--mh-surface);
box-shadow:
0 2px 4px rgb(0 0 0 / 4%),
0 16px 40px -12px rgb(0 0 0 / 18%);
}
.group-head {
margin-bottom: 6px;
padding-inline: 12px;
color: var(--mh-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.group-list {
display: grid;
gap: 2px;
}
.cell {
display: block;
width: 240px;
padding: 8px 12px;
border-radius: 10px;
color: var(--mh-ink);
text-decoration: none;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.cell:hover {
background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
}
.cell-title {
display: block;
font-size: 1rem;
font-weight: 500;
line-height: 1.35;
}
.cell[aria-current="page"] .cell-title {
color: var(--mh-accent);
}
.cell-desc {
display: block;
color: var(--mh-muted);
font-size: 0.875rem;
line-height: 1.3;
}
/* --- right-hand actions ---------------------------------------------------- */
.actions {
display: none;
flex: none;
gap: 8px;
margin-inline-start: auto;
}
.ghost,
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition:
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
filter 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ghost {
color: var(--mh-ink);
}
.ghost:hover {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.cta {
background: var(--mh-cta-bg);
color: var(--mh-cta-ink);
}
.cta:hover {
filter: brightness(1.18);
}
.wide {
width: 100%;
}
/* --- burger ----------------------------------------------------------------- */
.burger {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-inline-start: auto;
padding: 0;
border: 0;
border-radius: 10px;
background: none;
color: var(--mh-ink);
cursor: pointer;
}
.burger:hover {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.bars {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.bar-a,
.bar-b {
transform-origin: center;
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-mh-sheet-open] .bar-a {
transform: translateY(3px) rotate(45deg);
}
[data-mh-sheet-open] .bar-b {
transform: translateY(-3px) rotate(-45deg);
}
/* --- mobile sheet -------------------------------------------------------------- */
.sheet {
position: fixed;
inset: var(--mh-h) 0 0;
z-index: calc(var(--mh-z) - 1);
overflow-y: auto;
padding: 16px var(--mh-pad) 32px;
background: var(--mh-surface);
}
.sheet-nav {
display: flex;
flex-direction: column;
gap: 2px;
max-width: var(--mh-max);
margin-inline: auto;
}
.sheet-link,
.sheet-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 48px;
padding: 10px 12px;
border-radius: 10px;
color: var(--mh-ink);
font-size: 1.0625rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
list-style: none;
}
.sheet-summary::-webkit-details-marker {
display: none;
}
.sheet-link:hover,
.sheet-summary:hover {
background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
}
.sheet-link[aria-current="page"] {
color: var(--mh-accent);
}
.sheet-group[open] > .sheet-summary .chev {
transform: rotate(180deg);
}
.sheet-sub {
display: grid;
gap: 2px;
margin: 2px 0 8px;
padding-inline-start: 12px;
border-inline-start: 2px solid var(--mh-border);
}
.sheet-link.is-sub {
min-height: 44px;
font-size: 1rem;
font-weight: 400;
color: var(--mh-muted);
}
.sheet-link.is-sub:hover {
color: var(--mh-ink);
}
.sheet-actions {
display: grid;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--mh-border);
}
/* Actions with no navigation above them: no rule separating them from
nothing. */
.sheet-actions:first-child {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
/*
* Closed by default only once the script is running. Without JavaScript the
* sheet stays in the flow below the bar, every destination reachable, and
* the `<details>` groups still open and close on their own.
*/
[data-mh-js] .sheet {
display: none;
}
[data-mh-js][data-mh-sheet-open] .sheet {
display: block;
}
[data-mh-js] .sheet {
position: fixed;
}
.header:not([data-mh-js]) .sheet {
position: static;
inset: auto;
border-bottom: 1px solid var(--mh-border);
}
/* --- focus ---------------------------------------------------------------------- */
/* One ring for every control. The reference leaves the browser default,
which computes to about 0.67px. */
.brand:focus-visible,
.link:focus-visible,
.trigger:focus-visible,
.cell:focus-visible,
.ghost:focus-visible,
.cta:focus-visible,
.burger:focus-visible,
.sheet-link:focus-visible,
.sheet-summary:focus-visible {
outline: 3px solid var(--mh-accent);
outline-offset: 2px;
}
/* --- desktop breakpoint: measured at exactly 1024px ------------------------------- */
@media (min-width: 768px) {
.header {
--mh-pad: 32px;
}
}
@media (min-width: 1024px) {
.header {
--mh-pad: 64px;
}
.brand {
flex: none;
}
.brand-name {
white-space: nowrap;
}
.desk {
display: block;
margin-inline-start: 24px;
}
.actions {
display: flex;
}
.burger,
.sheet {
display: none;
}
/* Keeps the no-JS rule from resurrecting the sheet on desktop. */
.header:not([data-mh-js]) .sheet {
display: none;
}
}
@media (min-width: 1280px) {
.header {
--mh-pad: 80px;
}
.desk {
margin-inline-start: 40px;
}
}
/* --- reduced motion ----------------------------------------------------------------- */
/*
* Everything still happens — the bar still frosts, panels still open, the
* sheet still appears — it just happens instantly. The reference honours
* none of this; its 300ms header transition runs regardless.
*/
@media (prefers-reduced-motion: reduce) {
.bar,
.panel,
.chev,
.link,
.trigger,
.cell,
.ghost,
.cta,
.bar-a,
.bar-b {
transition-duration: 0.01ms;
}
}
</style>
<script>
/**
* One controller per instance, and one boot per page view.
*
* A header is installed into a shared layout rather than dropped onto one
* page, which changes what the script has to survive. A destination project
* running <ClientRouter /> swaps the document without re-evaluating this
* module, so booting hangs off `astro:page-load` as well as first load;
* booting twice over the same element must be free, so init is idempotent;
* and the listeners this puts on `document` and `window` would outlive
* every swap, so they are bound to an AbortController that is aborted
* before the document is replaced.
*/
/*
* Body scroll locking is a page-level concern, so it is counted at module
* scope rather than per instance. Two headers on one page — the catalog's
* own two-instance test does exactly this — would otherwise have the second
* one's close restore the page scroll while the first still has its sheet
* open over it.
*/
let scrollLocks = 0;
let savedOverflow = "";
function lockScroll() {
if (scrollLocks++ === 0) {
savedOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
}
}
function releaseScroll() {
if (scrollLocks === 0) return;
// Restore exactly what was there, rather than assuming "visible".
if (--scrollLocks === 0) document.body.style.overflow = savedOverflow;
}
function initStickyMegaMenuHeader(root: HTMLElement) {
// Idempotent: a second boot over the same element is a no-op rather
// than a second set of listeners on every trigger.
if (root.dataset.mhReady === "") return;
const bar = root.querySelector<HTMLElement>("[data-mh-bar]");
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-mh-item]"));
const burger = root.querySelector<HTMLButtonElement>("[data-mh-burger]");
const sheet = root.querySelector<HTMLElement>("[data-mh-sheet]");
if (!bar) return;
root.dataset.mhReady = "";
// Enables the JS-driven states. Without this the CSS keeps the panels on
// hover/focus-within and the sheet in the flow, so nothing is unreachable.
root.setAttribute("data-mh-js", "");
/* Every listener below carries this signal — including the document-level
dismissal handlers, which are the ones that would otherwise accumulate
one set per client-side navigation. */
const controller = new AbortController();
const { signal } = controller;
/* ---- scroll state: flips at the first pixel, as measured ---- */
let frame = 0;
function measure() {
const past = window.scrollY > 0;
if (past === root.hasAttribute("data-mh-scrolled")) return;
if (past) root.setAttribute("data-mh-scrolled", "");
else root.removeAttribute("data-mh-scrolled");
}
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}
window.addEventListener("scroll", schedule, { passive: true, signal });
measure();
/* ---- mega panels ---- */
let openItem: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function setOpen(item: HTMLElement | null) {
if (openItem === item) return;
/* One panel at a time within this instance, and never across two:
`items` was queried from this root, so another header's panels are
not in the list to be closed. */
for (const other of items) {
const on = other === item;
other.toggleAttribute("data-mh-open", on);
const trigger = other.querySelector<HTMLButtonElement>("[data-mh-trigger]");
trigger?.setAttribute("aria-expanded", String(on));
}
openItem = item;
}
// A short grace period, so the pointer can cross the gap between the
// trigger and the panel below it without the panel vanishing underneath.
function scheduleClose() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => setOpen(null), 260);
}
function cancelClose() {
clearTimeout(closeTimer);
}
for (const item of items) {
const trigger = item.querySelector<HTMLButtonElement>("[data-mh-trigger]");
if (!trigger) continue;
// Hover opens, matching the reference. Moving to another trigger
// swaps which panel is open rather than stacking two.
item.addEventListener(
"pointerenter",
() => {
cancelClose();
setOpen(item);
},
{ signal },
);
item.addEventListener("pointerleave", scheduleClose, { signal });
// Enter and Space both reach this through a <button>'s native click.
trigger.addEventListener(
"click",
() => {
cancelClose();
setOpen(openItem === item ? null : item);
},
{ signal },
);
// Tabbing out of the last link in a panel closes it, so focus never
// sits behind an invisible layer.
item.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (next && item.contains(next)) return;
if (openItem === item) setOpen(null);
},
{ signal },
);
}
/* ---- mobile sheet ---- */
let sheetOpen = false;
function setSheet(next: boolean) {
if (!burger || !sheet) return;
if (sheetOpen === next) return;
sheetOpen = next;
root.toggleAttribute("data-mh-sheet-open", next);
burger.setAttribute("aria-expanded", String(next));
if (next) lockScroll();
else releaseScroll();
}
burger?.addEventListener("click", () => setSheet(!sheetOpen), { signal });
sheet?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a")) setSheet(false);
},
{ signal },
);
/* ---- shared dismissal ---- */
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape") return;
if (openItem) {
const trigger = openItem.querySelector<HTMLButtonElement>("[data-mh-trigger]");
setOpen(null);
trigger?.focus();
} else if (sheetOpen) {
setSheet(false);
burger?.focus();
}
},
{ signal },
);
document.addEventListener(
"pointerdown",
(event) => {
if (root.contains(event.target as Node)) return;
setOpen(null);
if (sheetOpen) setSheet(false);
},
{ signal },
);
// Crossing the breakpoint with the sheet open would otherwise leave the
// body locked and a hidden sheet still flagged open.
const wide = window.matchMedia("(min-width: 1024px)");
const onChange = () => {
if (wide.matches && sheetOpen) setSheet(false);
if (!wide.matches && openItem) setOpen(null);
};
if (typeof wide.addEventListener === "function") {
wide.addEventListener("change", onChange, { signal });
}
/*
* Astro replaces the document on a client-side navigation. Close first,
* so a sheet that was open does not leave `overflow: hidden` on the body
* of the page being navigated to; then detach, and drop the ready flag
* so a re-used element can be booted again.
*/
document.addEventListener(
"astro:before-swap",
() => {
if (frame) cancelAnimationFrame(frame);
clearTimeout(closeTimer);
setSheet(false);
setOpen(null);
controller.abort();
delete root.dataset.mhReady;
},
{ once: true, signal },
);
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-mh-root]")
.forEach(initStickyMegaMenuHeader);
/* Astro's own <script> is a deferred module, so the DOM is already parsed
by the time this runs. The readyState check is for the case where this
file is copied into a project that inlines it some other way. */
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Fires on every view transition in a project using <ClientRouter />, and
never at all in one that does not. Registered once per module, so it
cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/StickyMegaMenuHeader.astro =====
===== FILE (supporting): src/components/StickyMegaMenuHeader.nav-data.ts =====
/**
* StickyMegaMenuHeader — navigation data, worked example.
*
* The component ships no menu of its own (Step 14): pass `items` or the header
* renders no navigation. This file is a worked example of that data. What it is for is the thing that actually goes
* wrong when a header is installed somewhere real: the destination project has
* its own routes, and someone has to map them into this shape. Doing that in a
* data file rather than inline in a layout means the routes can be read,
* reviewed and changed without touching markup.
*
* Copy it next to the component, edit every label and href to match the
* destination's real routes, and pass it in:
*
* ---
* import Header from "../components/StickyMegaMenuHeader.astro";
* import { primaryNav } from "../components/StickyMegaMenuHeader.nav-data";
* ---
* <Header items={primaryNav} currentPath={Astro.url.pathname} />
*
* The types come from the component itself, so this file cannot drift from
* what the component accepts: rename a field there and `astro check` fails
* here.
*/
import type { NavItem } from "./StickyMegaMenuHeader.astro";
/**
* Every route in the example tree is a placeholder. Replace them with routes
* that exist in the destination project — a header pointing at four 404s is
* worse than no header, because it looks finished.
*
* Shape rules the component enforces:
* - an item has `href` OR `groups`, never both;
* - an item with `groups` renders as a mega-panel trigger (a real `<button>`);
* - an item with `href` renders as a plain link;
* - `heading` on a group may be `""` to render a column with no heading.
*/
export const primaryNav: NavItem[] = [
{
label: "Product",
groups: [
{
heading: "By need",
links: [
{ label: "Overview", href: "/product", description: "What it does, in one page." },
{ label: "Integrations", href: "/product/integrations", description: "What it talks to." },
{ label: "Security", href: "/product/security", description: "How access is handled." },
],
},
{
heading: "By team",
links: [
{ label: "Engineering", href: "/teams/engineering" },
{ label: "Design", href: "/teams/design" },
{ label: "Operations", href: "/teams/operations" },
],
},
],
},
{
label: "Resources",
groups: [
{
heading: "Learn",
links: [
{ label: "Guides", href: "/guides", description: "Short, practical walkthroughs." },
{ label: "Changelog", href: "/changelog", description: "What shipped, and when." },
],
},
],
},
// A plain destination: no `groups`, so no panel and no trigger button.
{ label: "Pricing", href: "/pricing" },
{ label: "Contact", href: "/contact" },
];
===== END src/components/StickyMegaMenuHeader.nav-data.ts =====
===== FILE (supporting): src/components/StickyMegaMenuHeader.md =====
# Sticky mega menu header
A marketing site header that sticks to the top of the viewport, turns from
transparent to frosted glass the moment the page scrolls, opens wide mega panels
from its nav items, and collapses to a full-screen sheet below the desktop
breakpoint.
Catalog page: `/components/sticky-mega-menu-header`
## What the supplied selector matched
The selector was used verbatim, escapes intact, via `document.querySelector`. It
returned **exactly one element** at 1440px: a `<div class="hidden h-full lg:block">`
measuring **1425 × 64**, holding 7 links, 2 buttons and 3 inline SVGs.
Its contents: a logo link, a `<nav>` with two dropdown triggers ("Solutions",
"Resources") and four plain links, and a right-hand pair of actions.
## Desktop breakpoint, measured
**Exactly 1024px.** The matched element computes `display: block` at 1024 and
`display: none` at 1023 — verified either side of the boundary, not inferred
from Tailwind's default `lg`.
## The mobile equivalent, and why it is included
```
… > div:nth-child(2) > div.h-full.lg\:hidden
```
A **direct sibling** of the matched element, in the same header slot, shown by
the exact inverse of the same breakpoint. It carries the same primary
navigation: the same logo link, the same sign-up action, and a
`aria-label="Toggle navigation menu"` button that opens the site's menu.
It is the true counterpart, not an unrelated control — the two are mutually
exclusive halves of one header, and a component that reproduced only the desktop
half would be unusable below 1024px. Both are therefore reproduced in this one
file.
## Ancestor behavior incorporated
Two behaviours live outside the matched element and had to come with it, because
without them the navigation has neither its position nor its defining state
change. Both were folded into this component's own root rather than left as
wrappers a user must supply.
| Ancestor | Contribution |
| :--- | :--- |
| `div.sticky.top-0.z-50…duration-300` (2 levels up) | `position: sticky; top: 0; z-index: 50`, and `transition: background 300ms cubic-bezier(0.4, 0, 0.2, 1), box-shadow 300ms …` |
| `div.marketing-content-theme…` (4 levels up) | The page ground `rgb(252, 251, 248)`, which the scrolled state fades in at 75% alpha |
Nothing else was taken. No announcement bar, hero, page section, dialog, cookie
control, footer or other sticky layer was inspected or reproduced.
**One structural correction.** Sticky was put on this component's **root**, not
on the inner bar. A sticky element can only travel inside its own parent's box,
and the root here is exactly as tall as the bar — sticking the bar to the root
would give it nowhere to go and it would scroll away with the page. Measured
before the fix: the bar's top reached −24px instead of holding at 0.
## Reference measurements
| | Value |
| :--- | :--- |
| Header height | 64px, constant at every width and every scroll position |
| Inner max-width | 1280px |
| Horizontal padding | 80px ≥1280 · 64px ≥1024 · 32px ≥768 · 16px below |
| Logo | 105 × 18 |
| Nav link | 15px, padding 4px 6px |
| Dropdown trigger | 15px, padding 4px 0 4px 6px, with chevron |
| Left group gap | 56px |
| Action group gap | 8px |
| Secondary action | 60 × 32, padding 6px 10px, radius 8px, transparent, 14px |
| Primary action | 93 × 32, padding 6px 10px, radius 8px, `rgb(0 0 0 / 88%)` fill, 14px |
| Mega panel | 826 wide, cells 240 wide, cell padding 7px 12px 9px, radius 10px, title 16px |
| Mobile toggle | 40 × 40 |
### Scroll behavior
Measured at 0, 1, 50, 100, 300 and back to 0:
| Scroll | Background | Backdrop | Shadow | Height |
| :--- | :--- | :--- | :--- | :--- |
| 0 | transparent | none | none | 64 |
| **1** | page ground at **75%** | **blur(24px)** | present | 64 |
| 50 / 100 / 300 | unchanged from 1 | blur(24px) | present | 64 |
| back to 0 | transparent | none | none | 64 |
**The state flips at the very first pixel**, not at a threshold. Height, logo
size, link colour and the primary action are all unchanged — only background,
backdrop blur and shadow move. The blur is deliberately *not* in the transition
list, so it arrives instantly while the background and shadow fade over 300ms.
### Dropdown behavior
- Opens on **hover**; `aria-expanded` flips to `true` with no click.
- The panel is portaled to `position: fixed` and **slides** between triggers:
`transition: top 0.15s cubic-bezier(0.4, 0, 0.2, 1), left 0.15s, right 0.15s`.
- Moving to a second trigger closes the first — only one is ever open.
- The pointer can cross from trigger into panel without the panel closing.
- Moving the pointer away closes it.
- Escape closes it **and returns focus to the trigger**.
- Enter and Space both open it.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brandName` | `string` | `""` | Wordmark beside the mark. `""` renders the mark alone with a visually hidden "Home". |
| `brandHref` | `string` | `"/"` | Where the brand links to. |
| `navLabel` | `string` | `"Primary"` | Accessible name for the `<nav>`. |
| `menuLabel` | `string` | `"Toggle navigation menu"` | Accessible name for the small-screen button. |
| `items` | `NavItem[]` | `[]` | The navigation. An item with `groups` becomes a mega trigger; one with `href` is a plain link. |
| `loginLabel` / `loginHref` | `string` | `""` / — | Secondary action. Rendered only when both are given. |
| `ctaLabel` / `ctaHref` | `string` | `""` / — | Primary action. Rendered only when both are given. |
| `currentPath` | `string` | — | Marks the matching link `aria-current="page"`. |
**No invented site.** The component ships no brand, navigation tree or actions
of its own — the catalog preview passes its demo tree as props. Each block
renders only when it has content: no items, no desktop `<nav>`; no actions, no
action group; neither, no menu button and no sheet. With no brand name as well,
the component renders nothing at all.
| `height` | `string` | `"64px"` | Header height. |
| `maxWidth` | `string` | `"1280px"` | Caps the inner row. |
| `background` | `string` | `"#fcfbf8"` | Page ground; also the colour faded in when scrolled. |
| `surface` | `string` | `"#ffffff"` | Mega panels and the mobile sheet. |
| `ink` | `string` | `"#1a1a1a"` | Primary text. |
| `muted` | `string` | `"#6b6b6b"` | Descriptions and group headings. |
| `border` | `string` | `"#e4e2dc"` | Hairlines. |
| `ctaBg` / `ctaInk` | `string` | `"rgb(0 0 0 / 88%)"` / `"#fbfbfb"` | Primary button. |
| `accent` | `string` | `"#3b5bdb"` | Focus ring and the current-page marker. |
| `blur` | `string` | `"24px"` | Backdrop blur when scrolled. `0px` turns the glass off. |
| `zIndex` | `number` | `50` | Stacking order. |
| `class` / `id` | `string` | — | Added to the root; `id` also seeds panel and sheet ids. |
```ts
interface MegaLink { label: string; href: string; description?: string }
interface MegaGroup { heading?: string; links: MegaLink[] }
interface NavItem { label: string; href?: string; groups?: MegaGroup[] }
```
The breakpoint is fixed at 1024px and is **not** a prop: a media query cannot
read a custom property, so exposing it would be a lie.
## Dependencies and assets
| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins | none |
| Global stylesheet | none — every value is a `--mh-*` property on the root |
| Webfonts | none — system stack |
| **Images or icons** | **none** — the brand mark, chevron and burger are inline SVG written here |
| Network requests | none. Verified: zero external requests from the preview |
| JavaScript | ~90 lines, inline in the file |
## Responsive behavior
| Width | Layout |
| :--- | :--- |
| ≥ 1024px | Desktop row: brand, nav with mega panels, actions. Burger and sheet hidden. |
| < 1024px | Brand plus a 44 × 44 burger. Nav and actions move into the sheet. |
Inner padding follows the reference: 80 / 64 / 32 / 16px at ≥1280 / ≥1024 /
≥768 / below.
## Keyboard and focus
- The nav is a labelled `<nav>`; destinations are real `<a>`; triggers are real
`<button type="button">`.
- Each trigger carries `aria-expanded`, `aria-controls` pointing at a
per-instance panel id, and `aria-haspopup="true"`.
- Enter and Space operate triggers (native `<button>` behaviour).
- Escape closes an open panel and returns focus to its trigger; with the sheet
open instead, it closes the sheet and returns focus to the burger.
- **A closed panel's links are not focusable** — the panel is `visibility:
hidden`, so focus can never land behind an invisible layer. Verified by
calling `.focus()` on a closed panel link and confirming it did not take.
- Tabbing out of the last link in a panel closes it.
- Outside click closes panels and the sheet.
- Visible focus: a 3px accent outline at a 2px offset on every control.
- Every interactive target is at least 44px tall.
## Reduced motion
Under `prefers-reduced-motion: reduce` every transition drops to `0.01ms` —
the bar's frosting, the panels, the chevrons, the burger. Everything still
*happens*: the header still frosts, panels still open, the sheet still appears.
Only the animation goes. *The reference honours none of this; its 300ms header
transition runs regardless.*
## Without JavaScript
The fallback is real, not a placeholder:
- **Desktop** — the full nav is visible and all 16 destinations are reachable.
The mega panels still open, on `:hover` **and** on `:focus-within`, so a
keyboard user can tab into a trigger and then into its links. This is pure
CSS; the script only takes over to add `aria-expanded`, Escape and
outside-click.
- **Mobile** — the sheet is not a fixed overlay but a static block in the flow
directly under the bar, with all 16 destinations visible and the grouped items
as native `<details>` disclosures that still open and close.
What cannot be reproduced without JavaScript: the scroll-triggered frosting (it
needs a scroll listener), `aria-expanded` state reporting, Escape-to-close, and
body-scroll locking. None of those hide content.
## Multiple instances
**Verified with two on one page**, one using the defaults and one with a dark
palette, different items, a different `navLabel` and a different `currentPath`:
- Panel ids are prefixed per instance (`hdr-a-panel-0`, `hdr-b-panel-0`); every
`aria-controls` resolves within its own header and no id is duplicated.
- Opening a panel in the second left the first entirely closed.
- Each has its own colours, brand, nav label and item set.
- No id is emitted at all unless you pass one.
**Body scroll is restored exactly.** The sheet stores whatever `overflow` the
body had and puts it back on close, rather than assuming `visible`. Resizing
past the breakpoint with the sheet open closes it and unlocks the body —
verified: `overflow` went `hidden` → unset and the open flag cleared.
## Catalog preview
The entry sets `previewMode: "scroll"` with the hint **"Scroll to frost the
header"**, because a frame only as tall as the header has nothing to scroll and
the transparent-to-frosted change — the component's signature — could never be
seen. `hidePreviewHintInReducedMotion` is deliberately left unset: under reduced
motion scrolling still frosts the header, just instantly, so the prompt stays
true.
Because the header is **sticky rather than fixed**, the stage's lead spacer
genuinely pushes it down the document. `thumb.offsetY` is therefore set to 900
— matching the lead's cap — so the card frames the header itself rather than the
empty run-up. Verified: the header sits at y = 900 in a 1700px-tall thumbnail
frame, exactly at the crop's top edge.
None of that scaffolding is in the component. The source panel reports one
self-contained file and the copy button hands over exactly that — verified to
contain no `stage-*` class, no `previewMode`, no `previewHint` and no hint text.
## Copying it into another Astro project
1. Copy `StickyMegaMenuHeader.astro` into your components folder.
2. Render it once, at the top of your layout:
```astro
---
import StickyMegaMenuHeader from "../components/StickyMegaMenuHeader.astro";
---
<StickyMegaMenuHeader
brandName="Your brand"
currentPath={Astro.url.pathname}
items={[
{ label: "Product", groups: [{ heading: "Explore", links: [
{ label: "Overview", href: "/product", description: "What it does." },
] }] },
{ label: "Pricing", href: "/pricing" },
]}
ctaLabel="Get started"
ctaHref="/signup"
/>
```
3. There is no step three — it needs no assets and no configuration.
**It must not sit inside a short or clipping ancestor.** Sticky resolves against
the nearest scrolling box, so a parent with `overflow: hidden`/`auto`, a
`transform`, or a height barely taller than the header will stop it pinning.
Put it as a direct child of your layout's scrolling container. Nothing errors if
you do not — it simply scrolls away.
## Differences from the reference
| | Reference | Here |
| :--- | :--- | :--- |
| Trigger `aria-controls` | absent | present, per-instance, resolves |
| Trigger `aria-haspopup` | absent | `"true"` |
| `<nav>` label | none | `aria-label`, settable |
| Focus ring | browser default, ≈0.67px | 3px accent at 2px offset |
| Mobile toggle size | 40 × 40 | 44 × 44 |
| Reduced motion | ignored | all transitions off |
| Without JavaScript | untested here; panels are script-driven | panels open on hover and focus-within; sheet is a static disclosure list |
Two visual simplifications, both deliberate:
- **The panel does not slide between triggers.** The reference portals one fixed
panel and animates its `top`/`left`/`right` over 0.15s as you move between
triggers. Here each item owns its own panel, anchored beneath it, and switching
cross-fades. Reproducing the slide would mean a single portaled node measuring
each trigger on every pointer move — a large amount of machinery for a
0.15s flourish.
- **The gap between trigger and panel is padding, not a margin.** A real gap is
a dead zone that closes the panel as the pointer crosses it; padding keeps the
panel's hit box flush against the trigger. The visual offset is identical.
## Originality and licensing
Every line of markup, CSS and JavaScript here was written for this catalog. The
reference was measured in a browser — computed styles, bounding boxes, pointer
and keyboard probes, and scroll sampling — not copied. No source, Tailwind class
strings, logo, wordmark, navigation wording, icons, fonts, illustrations,
marketing copy, destination links, analytics or third-party scripts came from
it. The demo brand, nav labels and descriptions (passed by the catalog as
props, not built in) are invented. The three
icons — brand mark, chevron, burger — are original inline SVG of a few path
commands each. The component ships no files and makes no network requests, so
there is nothing to attribute and no third-party licence attached to it.
## Installing this as a production header
**Installation mode:** header kit, mega-menu navigation, **sticky** positioning, with desktop panels and a mobile sheet that locks body scroll.
This component is registered as **Navigation**, so its catalog page carries a
header-readiness panel, a header-installation prompt and a **Copy header kit**
action, and it has a full-page integration fixture at
`/integration-test/navigation/sticky-mega-menu-header/`. The difference between a visual
component and a production header, and what that machinery does, is in the
project README.
### Required files
| File | Destination | Required |
| :--- | :--- | :--- |
| `StickyMegaMenuHeader.astro` | `src/components/StickyMegaMenuHeader.astro` | yes |
| `StickyMegaMenuHeader.nav-data.ts` | `src/components/StickyMegaMenuHeader.nav-data.ts` | supporting — a worked route tree |
| `StickyMegaMenuHeader.md` | — | documentation |
One required file; the `.nav-data.ts` is a worked example you are meant to edit
rather than something the component imports. **Copy header kit** on the catalog
page hands over all of them, each fenced and labelled with its destination
path.
### Mapping routes into it
An item has `href` **or** `groups`, never both. With `groups` it becomes a
mega-panel trigger — a real `<button>`; with `href` it is a plain link.
```astro
---
import Header from "../components/StickyMegaMenuHeader.astro";
import { primaryNav } from "../components/StickyMegaMenuHeader.nav-data";
---
<Header items={primaryNav} currentPath={Astro.url.pathname} />
```
```ts
// StickyMegaMenuHeader.nav-data.ts — edit every route to a real one
import type { NavItem } from "./StickyMegaMenuHeader.astro";
export const primaryNav: NavItem[] = [
{ label: "Product", groups: [{ heading: "By need", links: [
{ label: "Overview", href: "/product", description: "What it does." },
] }] },
{ label: "Pricing", href: "/pricing" },
];
```
The types are imported from the component, so the data cannot drift from what
it accepts. The component has no tree of its own — pass one. The catalog's demo
tree is eight items deep in places: a site with three routes should pass a
smaller array, not pad one out.
### Replacing the existing header
1. Find the shared layout and the current header. Do not assume
`src/layouts/Layout.astro`.
2. Inventory every route in the existing header — label and href — before
deleting anything.
3. Render it as a direct child of `<body>`, after the skip link and before
`<main>`. **Not** inside a wrapper div (see positioning).
4. Pass `currentPath={Astro.url.pathname}`.
5. Build it alongside the old header first. Delete the old markup, its menu
JavaScript, its body-scroll lock and its CSS only once this one works on
every route — two body-scroll locks fighting is the classic symptom of
leaving the old one in.
6. Check no second header survives in a nested layout or an individual page.
### Positioning requirements
`position: sticky; top: 0` — it holds its own space, so **nothing needs
reserving**. Adding padding for it leaves a gap.
What does break it: an ancestor with `overflow` other than `visible`, or with a
`transform`, `filter` or `contain`. Any of those silently turn sticky into
static. That is the single most common reason it stops sticking in a
destination layout, and it is why step 3 says direct child of `<body>`.
Hash targets still need clearing, because a sticky bar covers them:
```css
:target, h1, h2 { scroll-margin-top: 6rem; }
```
Mega panels are full-bleed and escape the header's own max width, so a layout
that clips horizontal overflow above the header will clip them. The mobile
sheet is `position: fixed` below the bar and locks body scroll while open.
### Astro lifecycle behaviour
Installed once into a shared layout, so it has to survive a destination project
running `<ClientRouter />`, where the document is swapped without the bundled
module being re-evaluated.
- Boots on first load **and** on every `astro:page-load`. The `page-load`
listener is registered once per module, so it never accumulates.
- Init is idempotent: a `data-mh-ready` flag on the root means a second boot over
the same element is a no-op rather than a second set of listeners.
- Every listener — including the ones on `document` and `window` — carries an
`AbortController` signal. On `astro:before-swap` the instance closes its
state, releases its body-scroll lock, aborts the controller and clears its ready flag.
- The root carries `data-header-root` as well as its own `data-mh-root`, so tooling
can find a header without knowing which one it is.
In a project **without** a client router none of this fires and the component
behaves exactly as it always did.
### Client-navigation certification
**Result: `end-to-end-passed`, 2026-09-15.**
Not an event-level test. Certified by clicking real links under a real
`<ClientRouter />` on `/integration-test/navigation-client/sticky-mega-menu-header/`, waiting for
the router's own `astro:page-load`, over five transitions at 390px and 1440px
plus Back and Forward. Each transition was proved client-side by a `window`
sentinel that survived, a single unchanged Navigation Timing entry, and the
document never reloading.
Verified on every arrival:
- exactly one `[data-header-root]`, no duplicate ids, no broken `aria-controls`
- open state cleared and `aria-expanded` back to `false`
- body scroll released — the shared lock is counted at module scope, so two instances cannot release each other's
- the outgoing element detached, and clicking its trigger doing nothing
- the new instance initialised, with one activation causing exactly one change
- Escape, outside click and focus all working on the new page
- live document listeners holding at one per type while the aborted count grew
by one per transition — one live instance throughout, nothing accumulating
The header is **replaced** on every transition, not persisted. No
`transition:persist` is used: replacement is the harder case and the one a
destination project gets by default, and adding the directive to make a test
pass would hide the failure mode that matters.
Outside-click at mobile is reported honestly rather than claimed: while the
sheet is open it is fixed from the bar down and the bar spans the width, so no
point in the viewport is outside the header and there is nothing to click.
That was measured. Escape, the burger and picking a route are each tested
instead, and outside-click is tested at desktop where there is an outside.
### Verification checklist
Work all of it on `/integration-test/navigation/sticky-mega-menu-header/`, **and** in the
isolated preview at `/preview/sticky-mega-menu-header/`. A header that passes in one environment
and not the other is not ready.
- [ ] Widths 1440, 1280, 1024, 834, 390, 320 — `scrollWidth === clientWidth`
- [ ] 200% zoom (emulate it by halving the CSS viewport, not with `body { zoom }`)
- [ ] scrollY 0, 1, 100, 500, and back to 0
- [ ] Keyboard only, start to finish: no trap, no focus behind a closed layer
- [ ] Every dropdown: click, Enter, Space, Escape, focus return, outside click
- [ ] Mobile menu: open, close, Escape, focus return, link-click close
- [ ] Resize across the breakpoint while open
- [ ] Skip link is the first focusable element and its target is not covered
- [ ] Hash navigation leaves the target heading visible
- [ ] Astro client navigation, **end to end**: real link clicks under a real
`<ClientRouter />` on the navigation-client fixture, five transitions plus
Back and Forward. Dispatching the events is not enough to certify.
- [ ] JavaScript disabled — every link still reachable
- [ ] `prefers-reduced-motion: reduce`
- [ ] Two instances, independent, no shared state and no shared lock
- [ ] Body scroll restored after every close
- [ ] No duplicate ids
- [ ] No horizontal overflow, no clipped panel, no header over focused content
- [ ] No console errors, no requests originating in the component
===== END src/components/StickyMegaMenuHeader.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
No verification run has been recorded.
Passed: integration route, keyboard, no javascript, reduced motion, two instances, no overflow.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.