Component library
Back to catalog

Overlay pill header

A header laid over the hero: brand left, a frosted glass pill of links with one dropdown in the middle, a white pill CTA with a navy arrow disc on the right. Folds to a menu button and a drop-down card below 1024px.

navheaderoverlayglassdropdownpill

Open preview in a new tab

Use this component

Three ways to get Overlay pill 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 Overlay pill header from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/overlay-pill-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 ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.

--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.

--- 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
“Overlay pill header” from my Astro Component Library:
https://astro.baysixmedia.com/components/overlay-pill-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/OverlayPillHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/OverlayPillHeader.astrocomponentrequired
    • src/components/OverlayPillHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * OverlayPillHeader
 *
 * A site header that sits over the top of a hero: brand on the left, a
 * translucent glass pill of links in the middle (one of which can open a
 * dropdown), and a white pill call-to-action with a navy arrow disc on the
 * right. Below 1024px the pill and the CTA fold into a menu button and a
 * white card that drops under the bar.
 *
 * `overlay` (default true) positions the root absolutely at the top of its
 * containing block, so it scrolls away with the page over whatever the page
 * opens with. `overlay={false}` puts it back in the flow on a navy band.
 *
 * Reads --lx-* design tokens from any ancestor, with fallbacks, so it works
 * alone and re-themes with a page.
 */

export interface OverlayPillHeaderLink {
	label: string;
	href: string;
}

export interface OverlayPillHeaderItem {
	label: string;
	/** Ignored when `children` is present: the item becomes a dropdown trigger. */
	href?: string;
	/** Renders the item as a real <button> that opens a single-column panel. */
	children?: OverlayPillHeaderLink[];
}

export interface OverlayPillHeaderProps {
	items?: OverlayPillHeaderItem[];
	/** Pass Astro.url.pathname. Matching links get aria-current="page". */
	currentPath?: string;
	brandName?: string;
	brandHref?: string;
	/** Replaces the drawn mark and wordmark with an image. */
	logo?: { src: string; alt: string; width?: number; height?: number };
	cta?: { label: string; href: string } | null;
	/** Absolute over the hero (true) or in the flow on a navy band (false). */
	overlay?: boolean;
	navLabel?: string;
	menuLabel?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends OverlayPillHeaderProps {}

/* No invented site: links, brand and CTA are all the caller's. Each block
   renders only when its data is supplied; with none, nothing renders. */
const {
	items = [],
	currentPath,
	brandName,
	brandHref = "/",
	logo,
	cta = null,
	overlay = true,
	navLabel = "Primary",
	menuLabel = "Menu",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
	id,
} = Astro.props;

/* Ids for aria-controls. Seeded from `id` when given, random otherwise, so a
   second instance never collides with the first. */
const seed = id ? id : `oph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;

const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
	Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: OverlayPillHeaderItem) =>
	Boolean(item.children?.some((c) => isCurrent(c.href)));

const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasContent = hasBrand || hasItems || Boolean(cta);
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
	class:list={["oph", overlay ? "oph--overlay" : "oph--flow"]}
	id={id}
	data-header-root
	data-oph-root
>
	<div class="oph-bar">
		{hasBrand && (
		<a class="oph-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
			{
				logo ? (
					<img
						class="oph-logo-img"
						src={logo.src}
						alt={logo.alt}
						width={logo.width ?? 114}
						height={logo.height ?? 34}
					/>
				) : (
					<>
						<svg class="oph-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
							<rect width="40" height="40" rx="11" fill="var(--lx-accent, #ff5f00)" />
							<path
								d="M7 17.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
								fill="none"
								stroke="#fff"
								stroke-width="3.4"
								stroke-linecap="round"
							/>
							<path
								d="M7 25.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
								fill="none"
								stroke="var(--lx-navy, #0a2540)"
								stroke-width="3.4"
								stroke-linecap="round"
							/>
						</svg>
						<span class="oph-wordmark" aria-hidden="true">{brand}</span>
					</>
				)
			}
		</a>
		)}

		{hasItems && (
		<nav class="oph-nav" aria-label={navLabel}>
			<ul class="oph-pill" role="list">
				{
					items.map((item, i) => {
						if (item.children && item.children.length > 0) {
							const panelId = `${seed}-panel-${i}`;
							return (
								<li class="oph-item oph-item--dd" data-oph-dd>
									<button
										type="button"
										class="oph-link oph-trigger"
										aria-expanded="false"
										aria-controls={panelId}
										data-oph-trigger
										data-current={hasCurrentChild(item) ? "" : undefined}
									>
										{item.label}
										<svg class="oph-chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
											<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
										</svg>
									</button>
									<div class="oph-panel" id={panelId} data-oph-panel>
										<ul role="list">
											{item.children.map((child) => (
												<li>
													<a
														class="oph-panel-link"
														href={child.href}
														aria-current={isCurrent(child.href) ? "page" : undefined}
													>
														{child.label}
													</a>
												</li>
											))}
										</ul>
									</div>
								</li>
							);
						}
						return (
							<li class="oph-item">
								<a
									class="oph-link"
									href={item.href ?? "#"}
									aria-current={isCurrent(item.href) ? "page" : undefined}
								>
									{item.label}
								</a>
							</li>
						);
					})
				}
			</ul>
		</nav>
		)}

		{
			cta && (
				<a class="oph-cta" href={cta.href}>
					<span>{cta.label}</span>
					<span class="oph-cta-disc" aria-hidden="true">
						<svg viewBox="0 0 24 24" focusable="false">
							<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</span>
				</a>
			)
		}

		{(hasItems || cta) && (
		<button
			type="button"
			class="oph-burger"
			aria-expanded="false"
			aria-controls={menuId}
			data-oph-burger
		>
			<span class="oph-sr">{menuLabel}</span>
			<span class="oph-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
		</button>
		)}
	</div>

	{(hasItems || cta) && (
	<nav class="oph-menu" id={menuId} aria-label={navLabel} data-oph-menu>
		{hasItems && (
		<ul class="oph-menu-list" role="list">
			{
				items.map((item) =>
					item.children && item.children.length > 0 ? (
						<li class="oph-menu-group">
							<span class="oph-menu-label">{item.label}</span>
							<ul role="list">
								{item.children.map((child) => (
									<li>
										<a
											class="oph-menu-link"
											href={child.href}
											aria-current={isCurrent(child.href) ? "page" : undefined}
										>
											{child.label}
										</a>
									</li>
								))}
							</ul>
						</li>
					) : (
						<li>
							<a
								class="oph-menu-link"
								href={item.href ?? "#"}
								aria-current={isCurrent(item.href) ? "page" : undefined}
							>
								{item.label}
							</a>
						</li>
					),
				)
			}
		</ul>
		)}
		{
			cta && (
				<a class="oph-cta oph-cta--menu" href={cta.href}>
					<span>{cta.label}</span>
					<span class="oph-cta-disc" aria-hidden="true">
						<svg viewBox="0 0 24 24" focusable="false">
							<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
						</svg>
					</span>
				</a>
			)
		}
	</nav>
	)}
</header>
)}

<style>
	.oph {
		--oph-navy: var(--lx-navy, #0a2540);
		--oph-accent: var(--lx-accent, #ff5f00);
		--oph-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
		--oph-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
		--oph-dur: 220ms;

		box-sizing: border-box;
		width: 100%;
		padding: 24px 30px;
		font-family: var(--oph-body);
		color: #fff;
		z-index: 30;
	}

	.oph *,
	.oph *::before,
	.oph *::after {
		box-sizing: border-box;
	}

	.oph ul {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.oph--overlay {
		position: absolute;
		inset: 0 0 auto 0;
		background: transparent;
	}

	.oph--flow {
		position: relative;
		background: var(--oph-navy);
	}

	.oph-bar {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		max-width: 1200px;
		min-height: 50px;
		margin: 0 auto;
	}

	/* ---- brand ---- */
	.oph-brand {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		min-height: 44px;
		color: #fff;
		text-decoration: none;
		border-radius: 8px;
		flex: none;
	}

	.oph-mark {
		width: 34px;
		height: 34px;
		flex: none;
	}

	.oph-wordmark {
		font-family: var(--oph-display);
		font-size: 34px;
		line-height: 1;
		letter-spacing: -0.02em;
		text-transform: uppercase;
		padding-top: 3px;
	}

	.oph-logo-img {
		display: block;
		height: 34px;
		width: auto;
	}

	/* ---- the glass pill ---- */
	.oph-nav {
		display: none;
	}

	/* `.oph .oph-pill`, not `.oph-pill`: the `.oph ul` list reset would
	   otherwise outrank it and zero the pill's padding. */
	.oph .oph-pill {
		display: flex;
		align-items: center;
		gap: 40px;
		height: 50px;
		padding: 0 24px;
		border-radius: 100px;
		background: rgb(255 255 255 / 0.1);
		border: 1px solid rgb(255 255 255 / 0.16);
		-webkit-backdrop-filter: blur(12px);
		backdrop-filter: blur(12px);
	}

	.oph-item {
		position: relative;
	}

	.oph-link {
		display: inline-flex;
		align-items: center;
		gap: 4px;
		/* 14px type in a 44px target; the negative margin gives the space back
		   so the 40px rhythm between words is the reference's. */
		padding: 15px 8px;
		margin: -15px -8px;
		border: 0;
		border-radius: 100px;
		background: none;
		color: #fff;
		font: 600 14px/14px var(--oph-body);
		text-decoration: none;
		cursor: pointer;
		white-space: nowrap;
		transition: color var(--oph-dur) ease;
	}

	.oph-link:hover,
	.oph-link[aria-current="page"],
	.oph-trigger[data-current] {
		color: var(--oph-accent);
	}

	.oph-chev {
		width: 14px;
		height: 14px;
		transition: transform var(--oph-dur) ease;
	}

	.oph-trigger[aria-expanded="true"] .oph-chev {
		transform: rotate(180deg);
	}

	/* ---- dropdown panel ----
	   Without script the panel opens on hover and on focus-within, so every
	   link stays reachable. Once the controller has booted it owns the state,
	   and a closed panel is visibility: hidden — out of the tab order. */
	.oph-panel {
		position: absolute;
		top: calc(100% + 26px);
		left: -24px;
		min-width: 220px;
		padding: 20px;
		border-radius: 10px;
		background: #fff;
		box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
		opacity: 0;
		transform: translateY(-6px);
		pointer-events: none;
		transition:
			opacity var(--oph-dur) ease,
			transform var(--oph-dur) ease,
			visibility 0s linear var(--oph-dur);
		z-index: 2;
	}

	/* A bridge over the gap between trigger and panel, so pointer travel
	   from one to the other does not cross dead space. */
	.oph-panel::before {
		content: "";
		position: absolute;
		inset: -26px 0 100% 0;
	}

	.oph-panel ul {
		display: flex;
		flex-direction: column;
		gap: 4px;
	}

	.oph:not([data-oph-ready]) .oph-item--dd:hover .oph-panel,
	.oph:not([data-oph-ready]) .oph-item--dd:focus-within .oph-panel,
	.oph[data-oph-ready] .oph-item--dd[data-open] .oph-panel {
		opacity: 1;
		transform: none;
		pointer-events: auto;
		visibility: visible;
		transition:
			opacity var(--oph-dur) ease,
			transform var(--oph-dur) ease,
			visibility 0s;
	}

	.oph[data-oph-ready] .oph-item--dd:not([data-open]) .oph-panel {
		visibility: hidden;
	}

	.oph-panel-link {
		display: flex;
		align-items: center;
		min-height: 36px;
		padding: 0 10px;
		margin: 0 -10px;
		border-radius: 6px;
		color: var(--oph-navy);
		font: 600 14px/14px var(--oph-body);
		text-decoration: none;
		white-space: nowrap;
		transition: color var(--oph-dur) ease, background-color var(--oph-dur) ease;
	}

	.oph-panel-link:hover {
		background: var(--lx-surface, #f6f6f6);
		color: var(--oph-accent);
	}

	.oph-panel-link[aria-current="page"] {
		color: var(--oph-accent);
	}

	/* ---- CTA ---- */
	.oph-cta {
		display: none;
		align-items: center;
		gap: 24px;
		height: 50px;
		padding: 5px 5px 5px 19px;
		border-radius: 100px;
		background: #fff;
		color: var(--oph-navy);
		font: 600 14px/14px var(--oph-body);
		text-decoration: none;
		white-space: nowrap;
		flex: none;
	}

	.oph-cta-disc {
		display: grid;
		place-items: center;
		width: 40px;
		height: 40px;
		border-radius: 50%;
		background: var(--oph-navy);
		color: #fff;
		overflow: hidden;
		transition: background-color var(--oph-dur) ease;
	}

	.oph-cta-disc svg {
		width: 24px;
		height: 24px;
		transition: transform var(--oph-dur) ease;
	}

	.oph-cta:hover .oph-cta-disc {
		background: var(--oph-accent);
	}

	.oph-cta:hover .oph-cta-disc svg {
		transform: rotate(-45deg);
	}

	/* ---- burger ---- */
	.oph-burger {
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		margin-right: -7px;
		padding: 0;
		border: 0;
		border-radius: 8px;
		background: none;
		color: #fff;
		cursor: pointer;
	}

	/* No script, no toggle: the menu card is simply shown open below the bar,
	   so a button that could do nothing is not offered. */
	.oph:not([data-oph-ready]) .oph-burger {
		display: none;
	}

	.oph-burger-lines {
		position: relative;
		display: block;
		width: 22px;
		height: 14px;
	}

	.oph-burger-lines span {
		position: absolute;
		left: 0;
		width: 22px;
		height: 2px;
		border-radius: 1px;
		background: currentColor;
		transition: transform var(--oph-dur) ease, opacity var(--oph-dur) ease;
	}

	.oph-burger-lines span:nth-child(1) { top: 0; }
	.oph-burger-lines span:nth-child(2) { top: 6px; }
	.oph-burger-lines span:nth-child(3) { top: 12px; }

	.oph[data-oph-open] .oph-burger-lines span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
	.oph[data-oph-open] .oph-burger-lines span:nth-child(2) { opacity: 0; }
	.oph[data-oph-open] .oph-burger-lines span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }

	/* ---- mobile menu card ---- */
	.oph-menu {
		max-width: 1200px;
		margin: 24px auto 0;
		padding: 20px;
		border-radius: 10px;
		background: #fff;
		color: var(--oph-navy);
		box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
		transition:
			opacity var(--oph-dur) ease,
			transform var(--oph-dur) ease,
			visibility 0s linear var(--oph-dur);
	}

	/* With script, the card hangs under the bar out of the flow, so opening
	   it covers the hero rather than pushing the page down. */
	.oph[data-oph-ready] .oph-menu {
		position: absolute;
		top: calc(100% - 6px);
		left: 30px;
		right: 30px;
		margin: 0;
	}

	.oph[data-oph-ready]:not([data-oph-open]) .oph-menu {
		opacity: 0;
		transform: translateY(-8px);
		visibility: hidden;
		pointer-events: none;
	}

	.oph[data-oph-ready][data-oph-open] .oph-menu {
		transition:
			opacity var(--oph-dur) ease,
			transform var(--oph-dur) ease,
			visibility 0s;
	}

	.oph-menu-list {
		display: flex;
		flex-direction: column;
		gap: 2px;
	}

	.oph-menu-group {
		padding-bottom: 8px;
		margin-bottom: 6px;
		border-bottom: 1px solid rgb(10 37 64 / 0.14);
	}

	.oph-menu-label {
		display: block;
		padding: 6px 0 4px;
		color: #555;
		font: 500 12px/14px var(--oph-body);
		letter-spacing: 0.04em;
		text-transform: uppercase;
	}

	.oph-menu-link {
		display: flex;
		align-items: center;
		min-height: 44px;
		color: var(--oph-navy);
		font: 600 14px/14px var(--oph-body);
		text-decoration: none;
		border-radius: 6px;
	}

	.oph-menu-link[aria-current="page"] {
		color: var(--oph-accent);
	}

	.oph-cta--menu {
		display: flex;
		justify-content: space-between;
		margin-top: 14px;
		background: var(--oph-navy);
		color: #fff;
	}

	.oph-cta--menu .oph-cta-disc {
		background: #fff;
		color: var(--oph-navy);
	}

	/* ---- focus ---- */
	.oph a:focus-visible,
	.oph button:focus-visible {
		outline: 2px solid var(--oph-accent);
		outline-offset: 2px;
	}

	.oph-cta:focus-visible {
		outline-color: #fff;
		outline-offset: 3px;
	}

	.oph-menu a:focus-visible,
	.oph-panel a:focus-visible {
		outline-color: var(--oph-accent);
	}

	.oph-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip: rect(0 0 0 0);
		white-space: nowrap;
		border: 0;
	}

	/* ---- phone ---- */
	@media (max-width: 809.98px) {
		.oph {
			padding: 20px;
		}

		.oph-mark {
			width: 33px;
			height: 33px;
		}

		.oph-wordmark {
			font-size: 32px;
		}

		.oph-menu {
			margin-top: 20px;
		}

		.oph[data-oph-ready] .oph-menu {
			left: 20px;
			right: 20px;
		}
	}

	/* ---- desktop: the pill and CTA appear, the menu goes ---- */
	@media (min-width: 1024px) {
		.oph-nav {
			display: block;
			position: absolute;
			left: 50%;
			transform: translateX(-50%);
		}

		.oph-cta {
			display: inline-flex;
		}

		.oph-burger,
		.oph .oph-menu {
			display: none;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.oph {
			--oph-dur: 1ms;
		}
	}

	@media (forced-colors: active) {
		.oph-pill,
		.oph-panel,
		.oph-menu {
			border: 1px solid CanvasText;
		}
	}
</style>

<script>
	/**
	 * OverlayPillHeader controller.
	 *
	 * One closure per instance, every lookup scoped to that instance's root,
	 * every listener on an AbortController that is aborted before a
	 * client-side navigation swaps the document. No body scroll lock: the
	 * mobile menu is a card under the bar, not a sheet over the page.
	 */
	function initOverlayPillHeader(root: HTMLElement) {
		/* Idempotent: a second boot over the same element is a no-op. */
		if (root.dataset.ophReady === "") return;

		const burger = root.querySelector<HTMLButtonElement>("[data-oph-burger]");
		const menu = root.querySelector<HTMLElement>("[data-oph-menu]");
		const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-oph-dd]")];

		root.dataset.ophReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const desktop = window.matchMedia("(min-width: 1024px)");

		/* ---- dropdowns: one open at a time, per instance ---- */
		let openDd: HTMLElement | null = null;

		const triggerOf = (dd: HTMLElement) =>
			dd.querySelector<HTMLButtonElement>("[data-oph-trigger]");

		function closeDropdown(returnFocus = false) {
			if (!openDd) return;
			const dd = openDd;
			openDd = null;
			dd.removeAttribute("data-open");
			const trigger = triggerOf(dd);
			trigger?.setAttribute("aria-expanded", "false");
			if (returnFocus) trigger?.focus({ preventScroll: true });
		}

		function openDropdown(dd: HTMLElement) {
			if (openDd === dd) return;
			closeDropdown();
			openDd = dd;
			dd.setAttribute("data-open", "");
			triggerOf(dd)?.setAttribute("aria-expanded", "true");
		}

		dropdowns.forEach((dd) => {
			const trigger = triggerOf(dd);
			trigger?.addEventListener(
				"click",
				() => (openDd === dd ? closeDropdown() : openDropdown(dd)),
				{ signal },
			);
			/* Focus leaving the item entirely closes it, so Tab past the last
			   link does not leave a panel hanging open behind the focus. */
			dd.addEventListener(
				"focusout",
				(event) => {
					const next = event.relatedTarget as Node | null;
					if (openDd === dd && next && !dd.contains(next)) closeDropdown();
				},
				{ signal },
			);
		});

		/* ---- mobile menu ---- */
		let menuOpen = false;

		function setMenu(next: boolean, returnFocus = false) {
			if (!burger || !menu || menuOpen === next) return;
			menuOpen = next;
			root.toggleAttribute("data-oph-open", next);
			burger.setAttribute("aria-expanded", String(next));
			if (!next && returnFocus) burger.focus({ preventScroll: true });
		}

		burger?.addEventListener("click", () => setMenu(!menuOpen), { signal });

		/* Choosing a route closes the menu. */
		menu?.addEventListener(
			"click",
			(event) => {
				if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
			},
			{ signal },
		);

		/* Escape is bound to the document, not the root: after a client-side
		   navigation focus sits outside the header, and Escape must still work. */
		document.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "Escape") return;
				if (openDd) {
					event.preventDefault();
					const inside = openDd.contains(document.activeElement);
					closeDropdown(inside || document.activeElement === document.body);
				} else if (menuOpen) {
					event.preventDefault();
					setMenu(false, true);
				}
			},
			{ signal },
		);

		/* Outside click closes whichever is open. */
		document.addEventListener(
			"pointerdown",
			(event) => {
				const target = event.target as Node;
				if (openDd && !openDd.contains(target)) closeDropdown();
				if (menuOpen && !root.contains(target)) setMenu(false);
			},
			{ signal },
		);

		/* Crossing the breakpoint resets both: a menu open at phone width must
		   not be waiting open when the viewport narrows again later, and a
		   dropdown has no business staying open once its pill is hidden. */
		desktop.addEventListener(
			"change",
			() => {
				setMenu(false);
				closeDropdown();
			},
			{ signal },
		);

		function destroy() {
			closeDropdown();
			setMenu(false);
			controller.abort();
			delete root.dataset.ophReady;
		}

		document.addEventListener("astro:before-swap", destroy, { once: true, signal });
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-oph-root]").forEach(initOverlayPillHeader);

	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Registered once per module, so it cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>