Component library
Back to catalog

Scroll pill header

A 1350px pill bar fixed 20px from the top: a faint white wash with white type over the hero, solid white with navy type once the page scrolls. Centred links with a small dropdown, an orange quote pill whose arrow tilts on hover, and a round menu button with a white card below 1000px.

navheaderfixedpilldropdownscrolltideway

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/scroll-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
“Scroll pill header” from my Astro Component Library:
https://astro.baysixmedia.com/components/scroll-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/ScrollPillHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/ScrollPillHeader.astrocomponentrequired
    • src/components/ScrollPillHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * ScrollPillHeader
 *
 * A 1350px pill bar fixed 20px from the top of the viewport. Over the hero it
 * is a faint white wash with white type; once the page scrolls past
 * `threshold` it turns solid white with navy type. Logo on the left, links in
 * the middle (an item with `children` opens a small dropdown card), an orange
 * "Get a Quote" pill on the right. Below 1000px it folds to the logo and a
 * round menu button that opens a white card under the bar.
 *
 * Reads the --tw-* tokens from any ancestor, with fallbacks.
 */

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

export interface ScrollPillHeaderItem {
	label: string;
	/** Required for a plain link; ignored on a dropdown trigger. */
	href?: string;
	/** Turns the item into a dropdown trigger. */
	children?: ScrollPillHeaderLink[];
	/**
	 * Mega-menu shaped data from other headers in this library. The links of
	 * every group are flattened into one dropdown list; headings are dropped.
	 */
	groups?: { heading?: string; links: ScrollPillHeaderLink[] }[];
}

export interface ScrollPillHeaderProps {
	items?: ScrollPillHeaderItem[];
	/** Pass Astro.url.pathname. Matching links get aria-current="page". */
	currentPath?: string;
	brandName?: string;
	brandHref?: string;
	/** Replaces the drawn mark and wordmark. Give a light and a dark version. */
	logo?: { src: string; srcOnDark?: string; alt: string; width?: number; height?: number };
	/** The orange pill. `null` drops it. */
	cta?: ScrollPillHeaderLink | null;
	/** Always solid white, for pages whose top is not a dark hero. */
	solid?: boolean;
	/** Scroll distance in px after which the bar turns solid. */
	threshold?: number;
	navLabel?: string;
	menuLabel?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends ScrollPillHeaderProps {}

/* 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: rawItems = [],
	currentPath,
	brandName,
	brandHref = "/",
	logo,
	cta = null,
	solid = false,
	threshold = 8,
	navLabel = "Primary",
	menuLabel = "Menu",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
	id,
} = Astro.props;

const items = rawItems.map((item) => ({
	label: item.label,
	href: item.href ?? "#",
	children: item.children ?? item.groups?.flatMap((g) => g.links),
}));

const seed = id ? id : `sph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;

const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href?: string) => (href && SAFE.test(href.trim()) ? href.trim() : "#");

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

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

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
	class="sph"
	id={id}
	data-header-root
	data-sph-root
	data-sph-threshold={threshold}
	data-solid={solid ? "" : undefined}
>
	<div class="sph-bar">
		{hasBrand && (
		<a class="sph-brand" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brand} home`}>
			{
				logo ? (
					<>
						<img class="sph-logo sph-logo--light" src={logo.src} alt={logo.alt} width={logo.width ?? 167} height={logo.height ?? 43} />
						{logo.srcOnDark && (
							<img class="sph-logo sph-logo--dark" src={logo.srcOnDark} alt="" width={logo.width ?? 167} height={logo.height ?? 43} />
						)}
					</>
				) : (
					<>
						<svg class="sph-mark" viewBox="0 0 40 40" aria-hidden="true">
							<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
							<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
						</svg>
						<span class="sph-word">{brand}</span>
					</>
				)
			}
		</a>
		)}

		{hasItems && (
		<nav class="sph-nav" aria-label={navLabel}>
			<ul class="sph-list">
				{
					items.map((item, i) =>
						item.children?.length ? (
							<li class="sph-item sph-item--drop" data-sph-drop>
								<button
									type="button"
									class="sph-link sph-trigger"
									aria-expanded="false"
									aria-controls={`${seed}-drop-${i}`}
									data-sph-trigger
									data-current={hasCurrentChild(item) ? "" : undefined}
								>
									{item.label}
									<svg class="sph-chev" viewBox="0 0 12 12" aria-hidden="true">
										<path d="M2 4.25 6 8l4-3.75" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
									</svg>
								</button>
								<ul class="sph-drop" id={`${seed}-drop-${i}`} data-sph-panel>
									{item.children.map((child) => (
										<li>
											<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
												{child.label}
											</a>
										</li>
									))}
								</ul>
							</li>
						) : (
							<li class="sph-item">
								<a class="sph-link" href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
									{item.label}
								</a>
							</li>
						),
					)
				}
			</ul>
		</nav>
		)}

		{
			cta && (
				<a class="sph-cta" href={safeHref(cta.href)}>
					<span>{cta.label}</span>
					<span class="sph-cta-ic" aria-hidden="true">
						<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
					</span>
				</a>
			)
		}

		{hasMenu && (
		<button type="button" class="sph-burger" aria-expanded="false" aria-controls={menuId} data-sph-burger>
			<span class="sph-sr">{menuLabel}</span>
			<svg viewBox="0 0 24 24" aria-hidden="true">
				<circle cx="12" cy="12" r="9.25" fill="none" stroke="currentColor" stroke-width="1.5" />
				<path d="M12 7.5v9M7.5 12h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
			</svg>
		</button>
		)}
	</div>

	{hasMenu && (
	<div class="sph-menu" id={menuId} data-sph-menu>
		{hasItems && (
		<ul>
			{
				items.flatMap((item) =>
					item.children?.length
						? item.children.map((child) => (
								<li>
									<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
										{child.label}
									</a>
								</li>
							))
						: [
								<li>
									<a href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
										{item.label}
									</a>
								</li>,
							],
				)
			}
		</ul>
		)}
		{
			cta && (
				<a class="sph-cta sph-cta--menu" href={safeHref(cta.href)}>
					<span>{cta.label}</span>
					<span class="sph-cta-ic" aria-hidden="true">
						<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
					</span>
				</a>
			)
		}
	</div>
	)}
</header>
)}

<style>
	.sph {
		--sph-navy: var(--tw-navy, #0b4654);
		--sph-deep: var(--tw-navy-deep, #052931);
		--sph-accent: var(--tw-accent, #ff6d45);
		--sph-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
		--sph-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
		--sph-ease: cubic-bezier(0.44, 0, 0.56, 1);
		--sph-ink: #fff;

		position: fixed;
		top: 12px;
		left: 50%;
		z-index: 40;
		width: min(1350px, calc(100% - 24px));
		transform: translateX(-50%);
		font-family: var(--sph-body);
	}

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

	.sph[data-scrolled],
	.sph[data-solid],
	.sph[data-sph-open] {
		--sph-ink: var(--sph-navy);
	}

	.sph-bar {
		position: relative;
		display: flex;
		align-items: center;
		gap: 16px;
		height: 64px;
		padding: 0 12px 0 16px;
		border-radius: 999px;
		background: rgb(255 255 255 / 0.1);
		color: var(--sph-ink);
		/* No colour transition here: the links carry their own, and a second
		   one on the parent would chain behind it. */
		transition:
			background-color 0.4s var(--sph-ease),
			box-shadow 0.4s var(--sph-ease);
	}

	.sph[data-scrolled] .sph-bar,
	.sph[data-solid] .sph-bar,
	.sph[data-sph-open] .sph-bar {
		background: #fff;
		box-shadow: 0 0 0 1px rgb(11 70 84 / 0.08);
	}

	.sph-brand {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		margin-right: auto;
		color: inherit;
		text-decoration: none;
		border-radius: 8px;
	}

	.sph-mark {
		width: 32px;
		height: 32px;
		color: var(--sph-accent);
	}

	.sph-word {
		font: 500 26px/1 var(--sph-display);
		letter-spacing: -0.5px;
	}

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

	.sph-logo--dark {
		display: none;
	}

	.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark {
		display: block;
	}

	.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark ~ .sph-logo--light,
	.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--light:has(+ .sph-logo--dark) {
		display: none;
	}

	.sph-nav,
	.sph > .sph-bar > .sph-cta {
		display: none;
	}

	.sph-list {
		display: flex;
		align-items: center;
		gap: 35px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.sph-item {
		position: relative;
	}

	.sph-link {
		display: inline-flex;
		align-items: center;
		gap: 4px;
		padding: 8px 0;
		border: 0;
		background: none;
		color: inherit;
		font: 500 16px/25.6px var(--sph-body);
		letter-spacing: -0.32px;
		text-decoration: none;
		cursor: pointer;
		transition: color 0.3s var(--sph-ease);
	}

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

	.sph-chev {
		width: 14px;
		height: 14px;
		transition: transform 0.3s var(--sph-ease);
	}

	.sph-item--drop[data-open] .sph-chev {
		transform: rotate(180deg);
	}

	.sph-drop {
		position: absolute;
		top: calc(100% + 16px);
		left: 0;
		min-width: 290px;
		margin: 0;
		padding: 6px 10px;
		list-style: none;
		border-radius: 10px;
		background: #fff;
		box-shadow: 0 12px 30px rgb(5 41 49 / 0.14);
		opacity: 0;
		visibility: hidden;
		transform: translateY(6px);
		transition:
			opacity 0.25s var(--sph-ease),
			transform 0.25s var(--sph-ease),
			visibility 0s linear 0.25s;
	}

	/* A bridge over the gap, so moving the pointer from the trigger to the
	   card does not cross empty space and close it. */
	.sph-drop::before {
		content: "";
		position: absolute;
		inset: -18px 0 100% 0;
	}

	.sph-item--drop[data-open] .sph-drop {
		opacity: 1;
		visibility: visible;
		transform: none;
		transition-delay: 0s;
	}

	.sph-drop li + li {
		border-top: 1px solid rgb(11 70 84 / 0.1);
	}

	.sph-drop a {
		display: block;
		padding: 10px 0;
		color: var(--sph-navy);
		font: 500 16px/1.4 var(--sph-body);
		text-transform: uppercase;
		text-decoration: none;
		transition: color 0.3s var(--sph-ease);
	}

	.sph-drop a:hover,
	.sph-drop a[aria-current="page"] {
		color: var(--sph-accent);
	}

	.sph-cta {
		display: inline-flex;
		align-items: center;
		gap: 12px;
		height: 50px;
		padding: 0 8px 0 25px;
		border-radius: 999px;
		background: var(--sph-accent);
		color: #fff;
		font: 400 16px/25.6px var(--sph-body);
		letter-spacing: -0.32px;
		text-decoration: none;
		white-space: nowrap;
		transition: background-color 0.3s var(--sph-ease);
	}

	.sph-cta:hover {
		background: var(--sph-deep);
	}

	.sph-cta-ic {
		display: grid;
		place-items: center;
		width: 34px;
		height: 34px;
		border-radius: 50%;
		background: #fff;
		color: var(--sph-accent);
		transition: transform 0.3s var(--sph-ease);
	}

	.sph-cta-ic svg {
		width: 18px;
		height: 18px;
	}

	.sph-cta:hover .sph-cta-ic {
		transform: rotate(-45deg);
	}

	.sph-burger {
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 50%;
		background: none;
		color: var(--sph-accent);
		cursor: pointer;
	}

	.sph-burger svg {
		width: 26px;
		height: 26px;
		transition: transform 0.3s var(--sph-ease);
	}

	.sph[data-sph-open] .sph-burger svg {
		transform: rotate(45deg);
	}

	.sph-menu {
		position: absolute;
		top: calc(100% + 10px);
		left: 0;
		right: 0;
		display: none;
		padding: 12px 20px 20px;
		border-radius: 20px;
		background: #fff;
		box-shadow: 0 16px 40px rgb(5 41 49 / 0.18);
	}

	.sph[data-sph-open] .sph-menu {
		display: block;
	}

	.sph-menu ul {
		margin: 0 0 16px;
		padding: 0;
		list-style: none;
	}

	.sph-menu li + li {
		border-top: 1px solid rgb(11 70 84 / 0.1);
	}

	.sph-menu a:not(.sph-cta) {
		display: block;
		padding: 12px 0;
		color: var(--sph-navy);
		font: 500 17px/1.4 var(--sph-body);
		text-decoration: none;
	}

	.sph-menu a:not(.sph-cta):hover,
	.sph-menu a[aria-current="page"] {
		color: var(--sph-accent);
	}

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

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

	/* No JavaScript: no menu button, and the phone card is shown under the bar
	   as a plain list so every link stays reachable. */
	.sph:not([data-sph-ready]) .sph-burger {
		display: none;
	}

	.sph:not([data-sph-ready]) .sph-menu {
		display: block;
	}

	.sph:not([data-sph-ready]) .sph-bar {
		background: #fff;
		--sph-ink: var(--sph-navy);
		color: var(--sph-navy);
	}

	.sph:not([data-sph-ready]) .sph-item--drop:focus-within .sph-drop,
	.sph:not([data-sph-ready]) .sph-item--drop:hover .sph-drop {
		opacity: 1;
		visibility: visible;
		transform: none;
		transition-delay: 0s;
	}

	@media (min-width: 810px) {
		.sph {
			top: 20px;
			width: min(1350px, calc(100% - 60px));
		}

		.sph-bar {
			height: 80px;
			padding: 0 16px 0 22px;
		}
	}

	@media (min-width: 1000px) {
		.sph {
			width: min(1350px, calc(100% - 90px));
		}

		.sph-bar {
			height: 90px;
			padding: 0 20px 0 22px;
		}

		.sph-nav {
			display: block;
			position: absolute;
			left: 50%;
			transform: translateX(-50%);
		}

		.sph > .sph-bar > .sph-cta {
			display: inline-flex;
		}

		.sph-burger,
		.sph:not([data-sph-ready]) .sph-menu,
		.sph .sph-menu {
			display: none !important;
		}

		.sph-mark {
			width: 38px;
			height: 38px;
		}

		.sph-word {
			font-size: 32px;
		}

		.sph-logo {
			height: 43px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.sph-bar,
		.sph-link,
		.sph-chev,
		.sph-drop,
		.sph-cta,
		.sph-cta-ic,
		.sph-burger svg {
			transition: none;
		}

		.sph-cta:hover .sph-cta-ic {
			transform: none;
		}
	}

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

<script>
	/**
	 * ScrollPillHeader controller: the scroll swap, the desktop dropdowns and
	 * the phone menu. One closure per instance, every lookup scoped to its
	 * root, every listener on one AbortController aborted before a client-side
	 * navigation swaps the document. No body scroll lock.
	 */
	function initScrollPillHeader(root: HTMLElement) {
		if (root.dataset.sphReady === "") return;
		const burger = root.querySelector<HTMLButtonElement>("[data-sph-burger]");
		const menu = root.querySelector<HTMLElement>("[data-sph-menu]");

		root.dataset.sphReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const threshold = Number(root.dataset.sphThreshold) || 8;
		const desktop = window.matchMedia("(min-width: 1000px)");
		const hoverable = window.matchMedia("(hover: hover)");

		/* ---- scroll swap ---- */
		let ticking = false;
		const applyScroll = () => {
			ticking = false;
			root.toggleAttribute("data-scrolled", window.scrollY > threshold);
		};
		applyScroll();
		window.addEventListener(
			"scroll",
			() => {
				if (ticking) return;
				ticking = true;
				requestAnimationFrame(applyScroll);
			},
			{ passive: true, signal },
		);

		/* ---- dropdowns ---- */
		const drops = [...root.querySelectorAll<HTMLElement>("[data-sph-drop]")];
		const setDrop = (drop: HTMLElement, open: boolean, returnFocus = false) => {
			const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
			if (!trigger || drop.hasAttribute("data-open") === open) return;
			drop.toggleAttribute("data-open", open);
			trigger.setAttribute("aria-expanded", String(open));
			if (!open && returnFocus) trigger.focus({ preventScroll: true });
		};
		const closeDrops = (except?: HTMLElement) => drops.forEach((d) => d !== except && setDrop(d, false));

		for (const drop of drops) {
			const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
			if (!trigger) continue;
			trigger.addEventListener(
				"click",
				() => {
					const next = !drop.hasAttribute("data-open");
					closeDrops(drop);
					setDrop(drop, next);
				},
				{ signal },
			);
			drop.addEventListener(
				"pointerenter",
				(e) => {
					if (e.pointerType === "mouse" && hoverable.matches) {
						closeDrops(drop);
						setDrop(drop, true);
					}
				},
				{ signal },
			);
			drop.addEventListener(
				"pointerleave",
				(e) => {
					if (e.pointerType === "mouse" && hoverable.matches) setDrop(drop, false);
				},
				{ signal },
			);
			drop.addEventListener(
				"focusout",
				(e) => {
					const next = e.relatedTarget as Node | null;
					if (next && !drop.contains(next)) setDrop(drop, false);
				},
				{ signal },
			);
		}

		/* ---- phone menu ---- */
		let open = false;
		/* No links and no CTA means no menu button and no card: the scroll
		   swap and dropdowns above still run. */
		const setMenu = (next: boolean, returnFocus = false) => {
			if (!burger || !menu || open === next) return;
			open = next;
			root.toggleAttribute("data-sph-open", next);
			burger.setAttribute("aria-expanded", String(next));
			if (!next && returnFocus) burger.focus({ preventScroll: true });
		};
		burger?.addEventListener("click", () => setMenu(!open), { signal });
		menu?.addEventListener(
			"click",
			(e) => {
				if ((e.target as HTMLElement).closest("a[href]")) setMenu(false);
			},
			{ signal },
		);
		root.addEventListener(
			"focusout",
			(e) => {
				const next = e.relatedTarget as Node | null;
				if (open && next && !root.contains(next)) setMenu(false);
			},
			{ signal },
		);

		/* Escape and outside click on the document: after a client-side
		   navigation focus can sit outside the header. */
		document.addEventListener(
			"keydown",
			(e) => {
				if (e.key !== "Escape") return;
				const openDrop = drops.find((d) => d.hasAttribute("data-open"));
				if (openDrop) {
					e.preventDefault();
					setDrop(openDrop, false, openDrop.contains(document.activeElement));
				} else if (open) {
					e.preventDefault();
					setMenu(false, true);
				}
			},
			{ signal },
		);
		document.addEventListener(
			"pointerdown",
			(e) => {
				if (root.contains(e.target as Node)) return;
				closeDrops();
				setMenu(false);
			},
			{ signal },
		);
		desktop.addEventListener(
			"change",
			() => {
				setMenu(false);
				closeDrops();
			},
			{ signal },
		);

		document.addEventListener(
			"astro:before-swap",
			() => {
				setMenu(false);
				closeDrops();
				controller.abort();
				delete root.dataset.sphReady;
			},
			{ once: true, signal },
		);
	}

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

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

	document.addEventListener("astro:page-load", boot);
</script>