Component library
Back to catalog

Sticky mega menu header

A sticky site header that frosts on the first pixel of scroll, opens wide mega panels from its nav, and collapses to a full-screen sheet.

navheaderstickymega-menudropdown

Open preview in a new tab

Use this component

Three ways to get Sticky mega menu header into one of your own projects. All pre-filled with this component's name and library page.

Or, for the design alone

Placement prompt

What to do, where it goes and what must not change. Paste it into Claude Code inside the destination project.

Read it first
Add Sticky mega menu header from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/sticky-mega-menu-header/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

--- 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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Sticky mega menu header” from my Astro Component Library:
https://astro.baysixmedia.com/components/sticky-mega-menu-header/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/StickyMegaMenuHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/StickyMegaMenuHeader.astrocomponentrequired
    • src/components/StickyMegaMenuHeader.nav-data.tsroute datasupporting
    • src/components/StickyMegaMenuHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * 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>