Component librarylocal catalog
Back to catalog

Floating panel header

A fixed header that floats as a rounded bar, narrows into frosted glass once the page scrolls, and drops icon-row panels from its navigation.

headernavfixedscrolldropdownglassmobile-menu

Open preview in a new tab

Use this component

Four ways to get Floating panel header into one of your own projects, all pre-filled with this component's name and library page.

Or, for the design alone

Component reference

Paste it into 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 the branding and content open.

Read it first
“Floating panel header” from my Astro Component Library:
https://astro.baysixmedia.com/components/floating-panel-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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Floating panel header
Library page: https://astro.baysixmedia.com/components/floating-panel-header

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Floating panel header from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/floating-panel-header

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.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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.

src/components/library/FloatingPanelHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/FloatingPanelHeader.astrocomponentrequired
    • src/components/FloatingPanelHeader.nav-data.tsroute datasupporting
    • src/components/FloatingPanelHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * FloatingPanelHeader — a fixed header that floats as a rounded bar over the
 * page, narrows into frosted glass once you scroll, opens dropdown panels of
 * icon rows from its navigation, and collapses into a full-screen sheet below
 * the desktop breakpoint.
 *
 * One self-contained file. No imports, no companion files required, no npm
 * packages, no global stylesheet, no webfonts, no images, no network requests.
 * Static-only — no SSR, no endpoints, no cookies, no env.
 *
 * Design behaviour is inspired by the public Tailark Quartz "header five"
 * preview (https://pro.tailark.com/preview/quartz/header/five), observed in a
 * browser at several widths and scroll positions and rebuilt from those
 * measurements. No source, markup, CSS, script, branding, logo or artwork from
 * that page is used here, and the demo content is original. The reference is a
 * React/Tailwind block built on a headless menu library; this is one Astro file
 * with scoped CSS and about 200 lines of dependency-free TypeScript. See
 * FloatingPanelHeader.md.
 *
 * The scroll morph, measured at 1440px and reproduced as CSS transitions
 * rather than scripted per-frame styles:
 *
 *   at rest     1152px wide, transparent, no ring, no shadow, no blur
 *   narrowed    896px wide, surface at 75%, hairline ring, soft shadow,
 *               backdrop blur
 *   trigger     scrollY > 50 (configurable), reversible at the same point
 *   transition  500ms cubic-bezier(0.4, 0, 0.2, 1) on every property
 *
 * The bar's height never changes and it is `position: fixed`, so nothing in
 * the page moves at any point in the morph.
 *
 * One set of markup serves both layouts. A panel is an absolutely positioned
 * popup under its trigger at desktop widths and an in-flow accordion inside
 * the sheet below the breakpoint — the same button, the same `aria-expanded`,
 * the same links. Nothing is duplicated, so there is never a second copy of a
 * link hiding in the tab order.
 *
 * At and below `breakpoint` the morph is switched off and the header becomes a
 * bar with a menu button over a full-screen sheet. The breakpoint drives the
 * controller, the CSS layout and the no-script fallback alike; none of the
 * three is hardcoded.
 *
 * With no JavaScript there is no menu button — it could not open anything —
 * and the navigation is shown in place instead, with panels opening on hover
 * and focus so every link stays reachable by keyboard.
 *
 * Several instances can share a page: every query is scoped to the instance
 * root, ids are minted per instance, and the body scroll lock is counted at
 * module scope so two headers cannot release each other's.
 */

/** One row inside a dropdown panel. */
export interface PanelLink {
	/** Visible text. A row without one is dropped. */
	label: string;
	/** Destination. Unsafe or missing values render as plain text, never a link. */
	href: string;
	/**
	 * Second line under the label. Rows that have one are drawn as cards with
	 * a raised icon tile; rows that do not are drawn as a compact list.
	 */
	description?: string;
	/** Name from the built-in icon set. Unknown names fall back to a dot. */
	icon?: string;
	/** Opens in a new tab, with the matching rel. */
	external?: boolean;
}

/** A column inside a dropdown panel. */
export interface PanelGroup {
	/** Small uppercase heading above the column. `""` renders no heading. */
	heading?: string;
	links: PanelLink[];
}

/** A link at the foot of a panel, under the columns. */
export interface PanelFooterLink {
	label: string;
	href: string;
}

export interface NavItem {
	label: string;
	/** A plain destination. Give this OR `groups`, not both. */
	href?: string;
	/** Turns the item into a dropdown trigger. */
	groups?: PanelGroup[];
	/** Optional closing link under the panel's columns. Ignored without `groups`. */
	footer?: PanelFooterLink;
}

interface Props {
	/** Wordmark text beside the brand mark. `""` renders the mark alone. */
	brandName?: string;
	/** Where the brand links to. Unsafe or omitted renders a non-linked wordmark. */
	brandHref?: string;
	/** Accessible name for the `<nav>` landmark. */
	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"`. Compared after trimming. */
	currentPath?: string;
	/** Pixels of scroll before the bar narrows. The reference uses 50. */
	scrollThreshold?: number;
	/** Width at or below which the sheet replaces the inline navigation. */
	breakpoint?: number;
	/** Distance from the top of the viewport. */
	topOffset?: string;
	/** Width of the bar at rest. */
	maxWidth?: string;
	/** Width of the bar once narrowed. */
	scrolledMaxWidth?: string;
	/** Bar fill once narrowed. Mixed to 75% so the blur behind it reads. */
	surface?: string;
	/** Panel and card fill. */
	panelBackground?: string;
	/** Primary text. */
	ink?: string;
	/** Navigation labels, descriptions and headings. */
	muted?: string;
	/** Panel footer link, and the focus ring. */
	accent?: string;
	/** Backdrop blur for the narrowed bar and the sheet. `0px` turns it 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 {
	brandName = "Fieldline",
	brandHref = "/",
	navLabel = "Primary",
	menuLabel = "Menu",
	items = [
		{
			label: "Product",
			groups: [
				{
					heading: "Capabilities",
					links: [
						{
							label: "Automations",
							href: "/product/automations",
							description: "Rules that run without you",
							icon: "bolt",
						},
						{
							label: "Insights",
							href: "/product/insights",
							description: "Numbers you can act on",
							icon: "chart",
						},
						{
							label: "Guardrails",
							href: "/product/guardrails",
							description: "Approvals and an audit trail",
							icon: "shield",
						},
						{
							label: "Assist",
							href: "/product/assist",
							description: "Drafting help in every field",
							icon: "spark",
						},
					],
				},
			],
			footer: { label: "See the whole product", href: "/product" },
		},
		{
			label: "Solutions",
			groups: [
				{
					heading: "By team",
					links: [
						{
							label: "Operations",
							href: "/solutions/operations",
							description: "Plan the week in one view",
							icon: "compass",
						},
						{
							label: "Finance",
							href: "/solutions/finance",
							description: "Close the month on time",
							icon: "gem",
						},
						{
							label: "Engineering",
							href: "/solutions/engineering",
							description: "Ship without the paperwork",
							icon: "code",
						},
						{
							label: "Support",
							href: "/solutions/support",
							description: "Answer with the full history",
							icon: "chat",
						},
					],
				},
				{
					heading: "Library",
					links: [
						{ label: "Case studies", href: "/library/case-studies", icon: "book" },
						{ label: "Templates", href: "/library/templates", icon: "layers" },
						{ label: "Changelog", href: "/changelog", icon: "gauge" },
					],
				},
			],
		},
		{ label: "Pricing", href: "/pricing" },
		{ label: "Company", href: "/company" },
	],
	loginLabel = "Sign in",
	loginHref = "/signin",
	ctaLabel = "Talk to sales",
	ctaHref = "/contact",
	currentPath,
	scrollThreshold = 50,
	breakpoint = 1024,
	topOffset = "12px",
	maxWidth = "1152px",
	scrolledMaxWidth = "896px",
	surface = "#fafafa",
	panelBackground = "#ffffff",
	ink = "#09090b",
	muted = "#52525b",
	accent = "#2c64ff",
	blur = "8px",
	zIndex = 50,
	class: className,
	id,
}: Props = Astro.props;

/*
 * Link data is treated as untrusted. It can arrive from a CMS, a config file
 * or a loop over content entries, and a header is the one component on the
 * page that every route renders — so a bad href here is a bad href everywhere.
 *
 * Anything that is not a plain relative path, fragment, or http/https/mailto/
 * tel URL is refused. `javascript:` and `data:` are the ones that matter;
 * everything unrecognised is refused too, rather than allow-listed by
 * accident. A refused link renders as plain text, which is visible and
 * harmless, instead of silently vanishing.
 */
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;

function safeHref(value: unknown): string | undefined {
	if (typeof value !== "string") return undefined;
	const href = value.trim();
	if (href === "") return undefined;
	/* Strip control characters before testing: "java\tscript:" is a scheme too. */
	const bare = [...href]
		.filter((ch) => {
			const code = ch.codePointAt(0) ?? 0;
			return code > 31 && code !== 127;
		})
		.join("")
		.trim();
	if (
		bare.startsWith("/") ||
		bare.startsWith("#") ||
		bare.startsWith("./") ||
		bare.startsWith("../")
	) {
		return bare;
	}
	if (SAFE_SCHEME.test(bare)) return bare;
	/* A bare relative path like "about/" — no colon before the first slash. */
	const colon = bare.indexOf(":");
	const slash = bare.indexOf("/");
	if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
	return undefined;
}

/** Trailing slashes differ between routers; compare without them. */
const normalisePath = (value: string) => {
	const trimmed = value.trim();
	if (trimmed === "" || trimmed === "/") return "/";
	return trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
};

const current = typeof currentPath === "string" ? normalisePath(currentPath) : undefined;
const isCurrent = (href?: string) =>
	href !== undefined && current !== undefined && normalisePath(href) === current;

/*
 * The icon set.
 *
 * Drawn here rather than imported, so the component stays one file with no
 * package to install, and so a caller names an icon with a string instead of
 * passing markup: there is no prop that takes HTML and the component never
 * uses `set:html`. An unknown name falls back to a neutral dot rather than
 * rendering an empty tile.
 */
const ICONS: Record<string, string[]> = {
	bolt: ["M13.2 3.5 6 13h4.6l-.8 7.5L18 11h-4.6z"],
	chart: ["M4.8 19.4h14.4", "M8.2 16.4v-5.2", "M12 16.4V6.6", "M15.8 16.4V9.8"],
	shield: ["M12 3.6 5.8 6.1v5.2c0 4 2.6 7.1 6.2 8.1 3.6-1 6.2-4.1 6.2-8.1V6.1z", "m9.3 12.1 2 2 3.5-3.9"],
	spark: [
		"M12 4.2l1.5 4.3 4.3 1.5-4.3 1.5L12 15.8l-1.5-4.3L6.2 10l4.3-1.5z",
		"M18.2 14.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z",
	],
	compass: [
		"M12 4.4a7.6 7.6 0 1 0 0 15.2 7.6 7.6 0 0 0 0-15.2z",
		"m14.8 9.2-1.6 4.2-4.2 1.6 1.6-4.2z",
	],
	gem: ["m12 20.2-8-9.6 2.6-4.6h10.8L20 10.6z", "M4.2 10.6h15.6", "m9.2 6 2.8 14.2L14.8 6"],
	code: ["m9.2 8.8-3.6 3.4 3.6 3.4", "m14.8 8.8 3.6 3.4-3.6 3.4"],
	chat: [
		"M20 12.2c0 3.4-3.6 6.2-8 6.2-.9 0-1.8-.1-2.6-.4L5 19.5l1.2-3.1C4.8 15.3 4 13.8 4 12.2 4 8.8 7.6 6 12 6s8 2.8 8 6.2z",
	],
	book: ["M5.2 5.6A1.6 1.6 0 0 1 6.8 4h11v13.4h-11a1.6 1.6 0 0 0-1.6 1.6z", "M17.8 17.4V20H6.8"],
	layers: ["m12 4.4 7.4 3.8-7.4 3.8-7.4-3.8z", "m5.2 12.4 6.8 3.5 6.8-3.5", "m5.2 16.2 6.8 3.5 6.8-3.5"],
	gauge: ["M4.6 17.4a7.6 7.6 0 1 1 14.8 0", "m12 14 3.2-3.4"],
	users: [
		"M9.4 11.4a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z",
		"M3.8 19.4c0-3 2.5-5.4 5.6-5.4s5.6 2.4 5.6 5.4",
		"M16.2 5.4a3.2 3.2 0 0 1 0 6.2",
		"M17.4 14.3c2 .6 3.4 2.4 3.4 4.6",
	],
	lock: [
		"M7.4 10.4h9.2a1.4 1.4 0 0 1 1.4 1.4v6.4a1.4 1.4 0 0 1-1.4 1.4H7.4A1.4 1.4 0 0 1 6 18.2v-6.4a1.4 1.4 0 0 1 1.4-1.4z",
		"M8.8 10.4V8.2a3.2 3.2 0 0 1 6.4 0v2.2",
	],
	store: [
		"M4.6 9.6h14.8v8.9a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5z",
		"M4.2 9.6 5.9 5.2A1.5 1.5 0 0 1 7.3 4.3h9.4a1.5 1.5 0 0 1 1.4.9l1.7 4.4",
	],
	dot: [
		"M12 4.6a7.4 7.4 0 1 0 0 14.8 7.4 7.4 0 0 0 0-14.8z",
		"M12 9.4a2.6 2.6 0 1 0 0 5.2 2.6 2.6 0 0 0 0-5.2z",
	],
};

const iconPaths = (name?: string) =>
	(typeof name === "string" && ICONS[name]) || ICONS.dot;

/*
 * Normalise the tree once, so the template renders data rather than deciding
 * what is valid halfway down a nested map. A malformed entry is dropped rather
 * than rendered as an empty target.
 */
function hasLabel<T>(value: T): value is T & { label: string } {
	const label = (value as { label?: unknown } | null)?.label;
	return typeof label === "string" && label.trim() !== "";
}

const nav = (Array.isArray(items) ? items : []).filter(hasLabel).map((item, index) => {
	const groups = (Array.isArray(item.groups) ? item.groups : [])
		.map((group) => ({
			heading: typeof group?.heading === "string" ? group.heading.trim() : "",
			links: (Array.isArray(group?.links) ? group.links : [])
				.filter(hasLabel)
				.map((link) => {
					const href = safeHref(link.href);
					const description =
						typeof link.description === "string" ? link.description.trim() : "";
					return {
						label: link.label.trim(),
						href,
						description,
						paths: iconPaths(link.icon),
						external: href !== undefined && link.external === true,
						isCurrent: isCurrent(href),
					};
				}),
		}))
		.filter((group) => group.links.length > 0);

	const footerHref = safeHref(item.footer?.href);
	const footerLabel =
		typeof item.footer?.label === "string" ? item.footer.label.trim() : "";

	return {
		label: item.label.trim(),
		href: safeHref(item.href),
		groups,
		/* A column of rows is a card when its rows carry descriptions, and a
		   compact list when they do not — the reference draws both. */
		footer: footerHref && footerLabel ? { label: footerLabel, href: footerHref } : undefined,
		index,
	};
});

const homeHref = safeHref(brandHref);
const brandLabel = typeof brandName === "string" ? brandName.trim() : "";
const brandInitial = brandLabel.charAt(0) || "•";

const login = {
	label: typeof loginLabel === "string" ? loginLabel.trim() : "",
	href: safeHref(loginHref),
};
const cta = {
	label: typeof ctaLabel === "string" ? ctaLabel.trim() : "",
	href: safeHref(ctaHref),
};

/* Random rather than sequential: a module-level counter is shared by every
   render in the same server process, which is fine for one page and wrong the
   moment two pages are built in parallel and compared. */
const uid = id ?? `fp-${Math.random().toString(36).slice(2, 9)}`;
const menuId = `${uid}-menu`;

/*
 * The no-script fallback needs a real media query at the configured
 * breakpoint, and a media query cannot read a custom property. So one is
 * emitted per instance instead, keyed to this instance's uid.
 *
 * Delivered with `set:text`, never a raw-HTML directive: a `<style>` element's
 * text content is its stylesheet, so the safe text channel is also the correct
 * one. This component uses no raw-HTML injection anywhere. Because `set:text`
 * escapes HTML, and a `<style>`'s contents are raw text the parser does not
 * decode, an escaped character would survive into the CSS as an entity and
 * break the rule it sits in — so the string below is built from characters
 * that escaping cannot touch: no quotes, no angle brackets, no ampersands.
 *
 * `uid` can come from the `id` prop, which is caller data going into a
 * selector. It is reduced to `[A-Za-z0-9_-]` and forced to begin with a
 * letter, which makes it a valid CSS identifier and lets the attribute
 * selector go unquoted. `breakpoint` is already coerced to a positive number.
 * Nothing else reaches the stylesheet.
 */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "");
const cssUid = /^[A-Za-z]/.test(safeUid) ? safeUid : `fp-${safeUid || "instance"}`;
const bp = Math.max(0, Number(breakpoint) || 0) || 1024;

/*
 * What the fallback has to achieve, and why each line is here:
 *
 *   any width        no menu button. Without the controller it cannot open or
 *                    close anything, and a control that does nothing is worse
 *                    than no control. The base rule already hides it; only
 *                    `[data-fp-mobile]`, which the controller sets, brings it
 *                    back.
 *   at or below bp   the bar stacks and the navigation is shown in place under
 *                    it, with panels in the flow rather than as popups. The
 *                    bar scrolls itself if it is taller than the screen, and
 *                    never touches the page's own scrolling — the scroll lock
 *                    is controller-only.
 *
 * Panels stay closed until hovered or focused, which the scoped stylesheet
 * handles for the no-script case at every width, so a keyboard alone still
 * opens every one of them.
 */
const fallbackCss = `
@media (max-width:${bp}px){
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-row{display:flex;flex-direction:column;align-items:stretch;padding-block:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-head{min-height:56px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-menu{display:flex;flex-direction:column;width:100%;padding-bottom:12px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-list{flex-direction:column;align-items:stretch;gap:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-item{flex-direction:column;align-items:stretch}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-trigger,[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-link{width:100%;justify-content:space-between;min-height:52px;font-size:17px;border-radius:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-panel{position:static;transform:none;width:auto;max-width:none;margin-top:0;background:none;box-shadow:none;padding:0 0 8px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-columns{flex-direction:column;gap:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-column{background:none;box-shadow:none;padding:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-actions{width:100%;flex-direction:column;align-items:stretch;padding-top:12px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-bar{max-height:calc(100dvh - 2 * ${topOffset});overflow-y:auto}
}`;

const styleVars = [
	`--fp-top:${topOffset}`,
	`--fp-max:${maxWidth}`,
	`--fp-max-scrolled:${scrolledMaxWidth}`,
	`--fp-surface:${surface}`,
	`--fp-panel:${panelBackground}`,
	`--fp-ink:${ink}`,
	`--fp-muted:${muted}`,
	`--fp-accent:${accent}`,
	`--fp-blur:${blur}`,
	`--fp-z:${zIndex}`,
].join(";");

const Brand = homeHref ? "a" : "span";
---

<header
	class:list={["fp-header", className]}
	id={id}
	data-header-root
	data-fp-root
	data-fp-uid={cssUid}
	data-fp-threshold={String(Math.max(0, Number(scrollThreshold) || 0))}
	data-fp-breakpoint={String(bp)}
	style={styleVars}
>
	{/*
	    The no-script fallback, at the configured breakpoint. Inline because a
	    media query cannot read a custom property, and scoped to this instance
	    by uid so two headers with different breakpoints do not fight. Every
	    rule is inert the moment the controller adds `data-fp-ready`.
	*/}
	<style is:inline set:text={fallbackCss}></style>

	<div class="fp-bar" data-fp-bar>
		<div class="fp-row">
			<div class="fp-head">
				<Brand
					class="fp-brand"
					href={homeHref}
					aria-label={homeHref ? `${brandLabel} home` : undefined}
				>
					{/*
					    The mark is decorative: the word is inside the link and
					    stays in the accessibility tree, so the link keeps its
					    name whether or not a mark is supplied.
					*/}
					<span class="fp-mark" aria-hidden="true">
						<slot name="brand-mark">
							<span class="fp-mark-fallback">{brandInitial}</span>
						</slot>
					</span>
					{brandLabel && <span class="fp-word">{brandLabel}</span>}
				</Brand>

				<button
					class="fp-burger"
					type="button"
					data-fp-burger
					aria-expanded="false"
					aria-controls={menuId}
				>
					<span class="fp-sr">{menuLabel}</span>
					<svg class="fp-burger-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
						<path class="fp-burger-top" d="M4 7h16"></path>
						<path class="fp-burger-mid" d="M4 12h16"></path>
						<path class="fp-burger-bottom" d="M4 17h16"></path>
					</svg>
				</button>
			</div>

			{/*
			    Nav and actions live in one element so the menu button has a
			    single thing to control and the sheet has a single thing to
			    show. At desktop widths this element is `display: contents`, so
			    its two children become the middle and right cells of the bar's
			    own grid and no extra box sits between them.
			*/}
			<div class="fp-menu" id={menuId} data-fp-menu>
				<nav class="fp-nav" aria-label={navLabel}>
					<ul class="fp-list">
						{
							nav.map((item) => {
								const panelId = `${uid}-panel-${item.index}`;
								return item.groups.length > 0 ? (
									<li class="fp-item" data-fp-item>
										<button
											class="fp-trigger"
											type="button"
											data-fp-trigger
											aria-expanded="false"
											aria-controls={panelId}
										>
											{item.label}
											<svg
												class="fp-chevron"
												viewBox="0 0 16 16"
												aria-hidden="true"
												focusable="false"
											>
												<path d="M4 6.2 8 10.2l4-4"></path>
											</svg>
										</button>

										<div class="fp-panel" id={panelId} data-fp-panel>
											<div class="fp-columns">
												{item.groups.map((group) => {
													const cards = group.links.some((link) => link.description !== "");
													return (
														<div class:list={["fp-column", cards ? "is-cards" : "is-list"]}>
															{group.heading && (
																<p class="fp-heading">{group.heading}</p>
															)}
															<ul class="fp-cells">
																{group.links.map((link) => {
																	const Cell = link.href ? "a" : "span";
																	return (
																		<li>
																			<Cell
																				class:list={["fp-cell", !link.href && "is-inert"]}
																				href={link.href}
																				aria-current={link.isCurrent ? "page" : undefined}
																				target={link.external ? "_blank" : undefined}
																				rel={link.external ? "noopener noreferrer" : undefined}
																			>
																				<span class="fp-tile" aria-hidden="true">
																					<svg
																						class="fp-icon"
																						viewBox="0 0 24 24"
																						focusable="false"
																					>
																						{link.paths.map((d) => (
																							<path d={d}></path>
																						))}
																					</svg>
																				</span>
																				<span class="fp-cell-text">
																					<span class="fp-cell-title">{link.label}</span>
																					{link.description && (
																						<span class="fp-cell-desc">
																							{link.description}
																						</span>
																					)}
																				</span>
																			</Cell>
																		</li>
																	);
																})}
															</ul>
														</div>
													);
												})}
											</div>

											{item.footer && (
												<p class="fp-panel-foot">
													<a class="fp-panel-more" href={item.footer.href}>
														{item.footer.label}
													</a>
												</p>
											)}
										</div>
									</li>
								) : (
									<li class="fp-item">
										{item.href ? (
											<a
												class="fp-link"
												href={item.href}
												aria-current={isCurrent(item.href) ? "page" : undefined}
											>
												{item.label}
											</a>
										) : (
											<span class="fp-link is-inert">{item.label}</span>
										)}
									</li>
								);
							})
						}
					</ul>
				</nav>

				<div class="fp-actions">
					{login.label && login.href && (
						<a class="fp-ghost" href={login.href}>
							{login.label}
						</a>
					)}
					{cta.label && cta.href && (
						<a class="fp-cta" href={cta.href}>
							{cta.label}
						</a>
					)}
				</div>
			</div>
		</div>
	</div>
</header>

<style>
	.fp-header {
		/* ---- overridable tokens ---------------------------------------
		   Every one of these can be set from the props above, or from a
		   stylesheet in the destination project by targeting the element. */
		--fp-top: 12px;
		--fp-gutter: 8px;
		--fp-max: 1152px;
		--fp-max-scrolled: 896px;
		--fp-radius: 16px;
		--fp-pad: 12px;
		--fp-pad-sheet: 20px;
		--fp-row: 56px;
		--fp-surface: #fafafa;
		--fp-panel: #ffffff;
		--fp-ground: #fafafa;
		--fp-ink: #09090b;
		--fp-muted: #52525b;
		--fp-line: rgb(9 9 11 / 7.5%);
		--fp-ring: rgb(9 9 11 / 10%);
		--fp-hover: rgb(9 9 11 / 5%);
		--fp-accent: #2c64ff;
		--fp-blur: 8px;
		--fp-z: 50;
		--fp-duration: 500ms;
		--fp-ease: cubic-bezier(0.4, 0, 0.2, 1);
		--fp-font:
			ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		position: fixed;
		inset-inline: 0;
		top: 0;
		z-index: var(--fp-z);
		box-sizing: border-box;
		display: flex;
		flex-direction: column;
		align-items: center;
		padding-top: var(--fp-top);
		/* The dock spans the viewport; only the bar itself takes clicks. */
		pointer-events: none;
		color: var(--fp-ink);
		font-family: var(--fp-font);
		font-size: 16px;
		line-height: 1.5;
	}

	/* `:where` keeps specificity at zero, so a destination project's own rules
	   still win where it means them to. */
	.fp-header :where(*, *::before, *::after) {
		box-sizing: border-box;
	}

	.fp-header :where(ul) {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.fp-header :where(p) {
		margin: 0;
	}

	.fp-sr {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	/* ---------- the floating bar ---------- */

	/*
	 * Every property in the morph is declared here at its resting value and
	 * overridden once by `[data-fp-scrolled]`, so the reverse is the forward
	 * transition run backwards and there is no second path to drift out of
	 * sync. Width is a `min()` of two lengths in both states, which keeps it a
	 * plain length-to-length transition.
	 */
	.fp-bar {
		pointer-events: auto;
		position: relative;
		width: min(var(--fp-max), 100% - var(--fp-gutter) * 2);
		border-radius: var(--fp-radius);
		padding-inline: var(--fp-pad);
		background: transparent;
		box-shadow: none;
		backdrop-filter: blur(0px);
		-webkit-backdrop-filter: blur(0px);
		transition:
			width var(--fp-duration) var(--fp-ease),
			padding-inline var(--fp-duration) var(--fp-ease),
			background-color var(--fp-duration) var(--fp-ease),
			box-shadow var(--fp-duration) var(--fp-ease),
			backdrop-filter var(--fp-duration) var(--fp-ease),
			-webkit-backdrop-filter var(--fp-duration) var(--fp-ease);
	}

	[data-fp-scrolled] .fp-bar {
		width: min(var(--fp-max-scrolled), 100% - var(--fp-gutter) * 2);
		background: color-mix(in srgb, var(--fp-surface) 75%, transparent);
		box-shadow:
			0 0 0 1px var(--fp-line),
			0 4px 6px -1px rgb(0 0 0 / 6.5%),
			0 2px 4px -2px rgb(0 0 0 / 6.5%);
		backdrop-filter: blur(var(--fp-blur));
		-webkit-backdrop-filter: blur(var(--fp-blur));
	}

	/*
	 * Three cells: brand, navigation, actions. A grid rather than
	 * `justify-content: space-between`, because the reference centres its
	 * navigation on the bar itself — measured at 1440 and at 1026, the nav's
	 * centre sat within half a pixel of the bar's — and only equal outer
	 * tracks do that whatever the brand and the actions happen to measure.
	 */
	.fp-row {
		display: grid;
		grid-template-columns: 1fr auto 1fr;
		align-items: center;
		gap: 16px;
		min-height: var(--fp-row);
		padding-block: 12px;
	}

	.fp-head {
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 8px;
		min-width: 0;
	}

	.fp-menu {
		/* The two children become the grid's middle and right cells. */
		display: contents;
	}

	/* ---------- brand ---------- */

	.fp-brand {
		display: inline-flex;
		align-items: center;
		gap: 8px;
		/* 32px, like the nav controls, so the row lands on the reference's
		   56px bar rather than being pushed taller by the brand alone. On a
		   phone the whole head row is 56px, so the target grows with it. */
		min-height: 32px;
		color: var(--fp-ink);
		text-decoration: none;
	}

	.fp-mark {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		flex: 0 0 auto;
		width: 20px;
		height: 20px;
	}

	/* Sized here rather than on the element, so a caller's own mark lands at
	   the same size as the fallback without having to know the number. */
	.fp-mark > :global(svg),
	.fp-mark > :global(img) {
		display: block;
		width: 100%;
		height: 100%;
	}

	.fp-mark-fallback {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		border: 1.5px solid currentColor;
		border-radius: 6px;
		font-size: 0.6875rem;
		font-weight: 700;
		line-height: 1;
		text-transform: uppercase;
	}

	.fp-word {
		font-size: 1rem;
		font-weight: 600;
		letter-spacing: -0.015em;
		white-space: nowrap;
	}

	/* ---------- navigation ---------- */

	.fp-nav {
		grid-column: 2;
		justify-self: center;
		min-width: 0;
	}

	.fp-list {
		display: flex;
		align-items: center;
		gap: 12px;
	}

	.fp-item {
		position: relative;
		display: flex;
	}

	.fp-trigger,
	.fp-link {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		min-height: 32px;
		padding: 4px 16px;
		border: 0;
		border-radius: 6px;
		background: transparent;
		color: var(--fp-muted);
		font-family: inherit;
		font-size: 0.875rem;
		font-weight: 500;
		line-height: 1.25rem;
		text-decoration: none;
		white-space: nowrap;
		cursor: pointer;
		transition:
			background-color 150ms ease,
			color 150ms ease;
	}

	.fp-trigger:hover,
	.fp-link:hover,
	.fp-trigger:focus-visible,
	.fp-link:focus-visible,
	[data-fp-open] > .fp-trigger {
		background: var(--fp-hover);
		color: var(--fp-ink);
	}

	.fp-link[aria-current="page"] {
		color: var(--fp-ink);
	}

	.fp-link.is-inert {
		color: var(--fp-muted);
		cursor: default;
		opacity: 0.65;
	}

	.fp-chevron {
		width: 14px;
		height: 14px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.6;
		stroke-linecap: round;
		stroke-linejoin: round;
		transition: transform 200ms var(--fp-ease);
	}

	[data-fp-open] .fp-chevron {
		transform: rotate(180deg);
	}

	/* ---------- dropdown panels ---------- */

	/*
	 * Centred under the trigger. The reference offsets its panel by a third of
	 * its own width, which lands within a few pixels of centred for the two
	 * panels it ships; centring is the same picture and cannot drift off the
	 * bar when a panel is wider or narrower than theirs.
	 */
	.fp-panel {
		display: none;
		position: absolute;
		top: 100%;
		left: 50%;
		transform: translateX(-50%);
		margin-top: 6px;
		width: max-content;
		max-width: min(560px, calc(100vw - 32px));
		border-radius: 12px;
		background: var(--fp-ground);
		box-shadow:
			0 0 0 1px var(--fp-line),
			0 10px 15px -3px rgb(0 0 0 / 6.5%),
			0 4px 6px -4px rgb(0 0 0 / 6.5%);
	}

	[data-fp-open] > .fp-panel,
	/* Without the controller, hover and focus are the only way in — and they
	   have to work, or the panels' links are unreachable. */
	.fp-header:not([data-fp-ready]) .fp-item:hover > .fp-panel,
	.fp-header:not([data-fp-ready]) .fp-item:focus-within > .fp-panel {
		display: block;
		animation: fp-panel-in 200ms var(--fp-ease) both;
	}

	@keyframes fp-panel-in {
		from {
			opacity: 0;
			transform: translateX(-50%) scale(0.97);
		}
	}

	.fp-columns {
		display: flex;
		gap: 6px;
	}

	/* The elevated column: rows with descriptions, on a raised card. */
	.fp-column.is-cards {
		width: 272px;
		border-radius: 12px;
		padding: 2px;
		padding-top: 8px;
		background: var(--fp-panel);
		box-shadow:
			0 0 0 1px var(--fp-line),
			0 1px 3px rgb(0 0 0 / 8%),
			0 1px 2px -1px rgb(0 0 0 / 8%);
	}

	/* The plain column: label-only rows, straight onto the panel ground. */
	.fp-column.is-list {
		width: 236px;
		padding: 8px 2px 2px;
	}

	.fp-heading {
		margin-inline-start: 12px;
		margin-bottom: 4px;
		color: var(--fp-muted);
		font-size: 0.75rem;
		font-weight: 500;
		line-height: 1rem;
		text-transform: uppercase;
	}

	.fp-cell {
		display: grid;
		grid-template-columns: auto 1fr;
		align-items: center;
		gap: 10px;
		padding: 8px 12px;
		border-radius: 11px;
		color: var(--fp-ink);
		text-decoration: none;
		transition: background-color 150ms ease;
	}

	.is-cards .fp-cell {
		padding: 12px;
	}

	.fp-cell:hover,
	.fp-cell:focus-visible {
		background: var(--fp-hover);
	}

	.fp-cell.is-inert {
		cursor: default;
		opacity: 0.65;
	}

	.fp-cell[aria-current="page"] {
		background: rgb(9 9 11 / 3%);
	}

	.fp-tile {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		flex: 0 0 auto;
		width: 16px;
		height: 16px;
		border-radius: 8px;
		color: var(--fp-muted);
	}

	/* Only the card rows get the raised tile; the list rows show a bare icon,
	   exactly as the reference draws them. */
	.is-cards .fp-tile {
		width: 36px;
		height: 36px;
		background: var(--fp-panel);
		box-shadow:
			0 0 0 1px var(--fp-ring),
			0 1px 3px rgb(0 0 0 / 10%),
			0 1px 2px -1px rgb(0 0 0 / 10%);
		background-image: radial-gradient(
			circle at 50% 0%,
			rgb(9 9 11 / 3%),
			transparent 70%
		);
	}

	.fp-icon {
		width: 16px;
		height: 16px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.6;
		stroke-linecap: round;
		stroke-linejoin: round;
	}

	.fp-cell-text {
		display: flex;
		flex-direction: column;
		gap: 2px;
		min-width: 0;
	}

	.fp-cell-title {
		font-size: 0.875rem;
		font-weight: 500;
		line-height: 1.25rem;
	}

	.fp-cell-desc {
		overflow: hidden;
		color: var(--fp-muted);
		font-size: 0.75rem;
		line-height: 1rem;
		text-overflow: ellipsis;
		white-space: nowrap;
	}

	.fp-panel-foot {
		padding: 14px 16px 16px;
	}

	.fp-panel-more {
		color: var(--fp-accent);
		font-size: 0.875rem;
		font-weight: 500;
		text-decoration: none;
	}

	.fp-panel-more:hover {
		text-decoration: underline;
		text-underline-offset: 3px;
	}

	/* ---------- actions ---------- */

	.fp-actions {
		display: flex;
		grid-column: 3;
		justify-self: end;
		align-items: center;
		gap: 12px;
	}

	.fp-ghost,
	.fp-cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		min-height: 32px;
		padding-inline: 12px;
		border-radius: 6px;
		color: var(--fp-ink);
		font-size: 0.75rem;
		font-weight: 500;
		text-decoration: none;
		white-space: nowrap;
		transition:
			background-color 150ms ease,
			box-shadow 150ms ease;
	}

	.fp-ghost:hover {
		background: var(--fp-hover);
	}

	.fp-cta {
		background: var(--fp-panel);
		box-shadow:
			0 0 0 1px var(--fp-line),
			0 1px 2px rgb(0 0 0 / 6.5%);
	}

	.fp-cta:hover {
		box-shadow:
			0 0 0 1px var(--fp-ring),
			0 1px 3px rgb(0 0 0 / 10%);
	}

	/* ---------- menu button ---------- */

	.fp-burger {
		display: none;
		align-items: center;
		justify-content: center;
		flex: 0 0 auto;
		width: 44px;
		height: 44px;
		margin-inline-end: -10px;
		padding: 0;
		border: 0;
		border-radius: 10px;
		background: transparent;
		color: var(--fp-ink);
		cursor: pointer;
	}

	.fp-burger-icon {
		width: 20px;
		height: 20px;
		fill: none;
		stroke: currentColor;
		stroke-width: 2;
		stroke-linecap: round;
	}

	.fp-burger-icon path {
		transform-origin: 12px 12px;
		transition:
			transform 200ms var(--fp-ease),
			opacity 150ms ease;
	}

	[data-fp-sheet-open] .fp-burger-top {
		transform: translateY(5px) rotate(45deg);
	}

	[data-fp-sheet-open] .fp-burger-mid {
		opacity: 0;
	}

	[data-fp-sheet-open] .fp-burger-bottom {
		transform: translateY(-5px) rotate(-45deg);
	}

	/* ---------- small screens ----------
	   The breakpoint is a prop, so the controller mirrors it onto the root as
	   `data-fp-mobile` and the layout keys off that attribute rather than a
	   media query, which could not read the prop. The no-script fallback needs
	   a genuine media query, so it is emitted per instance in the markup above
	   at the same configured value. */

	[data-fp-mobile] .fp-row {
		display: flex;
		flex-direction: column;
		align-items: stretch;
		gap: 0;
		padding-block: 0;
	}

	[data-fp-mobile] .fp-head {
		min-height: var(--fp-row);
	}

	[data-fp-mobile] .fp-burger {
		display: inline-flex;
	}

	[data-fp-mobile] .fp-menu {
		display: none;
	}

	[data-fp-mobile][data-fp-sheet-open] .fp-menu {
		display: flex;
		flex-direction: column;
		width: 100%;
		padding-bottom: 12px;
		animation: fp-sheet-in 220ms var(--fp-ease) both;
	}

	@keyframes fp-sheet-in {
		from {
			opacity: 0;
			transform: translateY(-6px);
		}
	}

	/* The open sheet: a full-screen frosted ground with the bar as its card. */
	[data-fp-mobile][data-fp-sheet-open].fp-header {
		bottom: 0;
		pointer-events: auto;
		background: color-mix(in srgb, var(--fp-panel) 75%, transparent);
		backdrop-filter: blur(var(--fp-blur));
		-webkit-backdrop-filter: blur(var(--fp-blur));
	}

	[data-fp-mobile][data-fp-sheet-open] .fp-bar {
		max-height: calc(100dvh - var(--fp-top) * 2);
		overflow-y: auto;
		padding-inline: var(--fp-pad-sheet);
		background: color-mix(in srgb, var(--fp-surface) 75%, transparent);
		box-shadow:
			0 0 0 1px var(--fp-line),
			0 4px 6px -1px rgb(0 0 0 / 6.5%),
			0 2px 4px -2px rgb(0 0 0 / 6.5%);
		backdrop-filter: blur(var(--fp-blur));
		-webkit-backdrop-filter: blur(var(--fp-blur));
	}

	[data-fp-mobile][data-fp-sheet-open] .fp-head {
		border-bottom: 1px solid var(--fp-line);
	}

	[data-fp-mobile] .fp-nav {
		justify-self: stretch;
	}

	[data-fp-mobile] .fp-list {
		flex-direction: column;
		align-items: stretch;
		gap: 0;
	}

	[data-fp-mobile] .fp-item {
		flex-direction: column;
	}

	[data-fp-mobile] .fp-trigger,
	[data-fp-mobile] .fp-link {
		justify-content: space-between;
		width: 100%;
		min-height: 52px;
		padding-inline: 0;
		border-radius: 0;
		border-bottom: 1px solid var(--fp-line);
		color: var(--fp-ink);
		font-size: 1.0625rem;
	}

	[data-fp-mobile] .fp-trigger {
		font-weight: 600;
	}

	[data-fp-mobile] .fp-link {
		font-weight: 400;
	}

	[data-fp-mobile] .fp-item[data-fp-open] > .fp-trigger {
		margin-inline: -12px;
		padding-inline: 12px;
		border-bottom-color: transparent;
		border-radius: 10px;
		width: auto;
	}

	/* In the flow, not a popup: the panel becomes the accordion's contents. */
	[data-fp-mobile] .fp-panel {
		position: static;
		transform: none;
		width: auto;
		max-width: none;
		margin-top: 0;
		padding-bottom: 8px;
		border-radius: 0;
		background: none;
		box-shadow: none;
	}

	[data-fp-mobile] .fp-item[data-fp-open] > .fp-panel {
		animation: none;
	}

	[data-fp-mobile] .fp-columns {
		flex-direction: column;
		gap: 0;
	}

	[data-fp-mobile] .fp-column.is-cards,
	[data-fp-mobile] .fp-column.is-list {
		width: auto;
		padding: 0;
		border-radius: 0;
		background: none;
		box-shadow: none;
	}

	[data-fp-mobile] .fp-heading {
		margin-block: 8px 2px;
		margin-inline-start: 4px;
	}

	[data-fp-mobile] .fp-cell,
	[data-fp-mobile] .is-cards .fp-cell {
		min-height: 44px;
		padding: 8px 4px;
	}

	/* One row height for every entry on a phone: the descriptions are the
	   first thing to go, exactly as the reference drops them. */
	[data-fp-mobile] .fp-cell-desc {
		display: none;
	}

	[data-fp-mobile] .is-cards .fp-tile {
		width: 20px;
		height: 20px;
		border-radius: 0;
		background: none;
		background-image: none;
		box-shadow: none;
	}

	[data-fp-mobile] .fp-panel-foot {
		padding: 4px 4px 8px;
	}

	[data-fp-mobile] .fp-actions {
		flex-direction: column;
		align-items: stretch;
		justify-self: stretch;
		gap: 8px;
		padding-top: 16px;
		border-top: 1px solid var(--fp-line);
	}

	[data-fp-mobile] .fp-ghost,
	[data-fp-mobile] .fp-cta {
		min-height: 44px;
		font-size: 0.8125rem;
	}

	/* ---------- focus ---------- */

	.fp-brand:focus-visible,
	.fp-trigger:focus-visible,
	.fp-link:focus-visible,
	.fp-cell:focus-visible,
	.fp-panel-more:focus-visible,
	.fp-ghost:focus-visible,
	.fp-cta:focus-visible,
	.fp-burger:focus-visible {
		outline: 3px solid color-mix(in srgb, var(--fp-accent) 50%, transparent);
		outline-offset: 1px;
		border-radius: 6px;
	}

	/* ---------- reduced motion ----------
	   Every state change still happens; it just arrives at once. Nothing is
	   hidden and nothing is left mid-transition. */
	@media (prefers-reduced-motion: reduce) {
		.fp-header {
			--fp-duration: 1ms;
		}

		.fp-chevron,
		.fp-trigger,
		.fp-link,
		.fp-cell,
		.fp-ghost,
		.fp-cta,
		.fp-burger-icon path {
			transition-duration: 1ms;
		}

		[data-fp-open] > .fp-panel,
		.fp-header:not([data-fp-ready]) .fp-item:hover > .fp-panel,
		.fp-header:not([data-fp-ready]) .fp-item:focus-within > .fp-panel,
		[data-fp-mobile][data-fp-sheet-open] .fp-menu {
			animation-duration: 1ms;
		}
	}
</style>

<script>
	/**
	 * One controller per instance, one boot per page view.
	 *
	 * A header is installed once into a shared layout, so it has to survive a
	 * destination project running <ClientRouter />: the document is swapped
	 * without this module being re-evaluated, the element is replaced, and
	 * anything left on `document` or `document.body` carries into the next
	 * route. Hence: boot on `astro:page-load` as well as first load,
	 * idempotent init, and every listener bound to an AbortController that is
	 * aborted before the swap.
	 */

	/*
	 * Scroll locking is a page-level concern, so it is counted at module scope
	 * rather than per instance. Two headers on one page would otherwise have
	 * the second one's close restore scrolling while the first still has its
	 * sheet open over it.
	 */
	let scrollLocks = 0;
	let savedOverflow = "";
	let savedPaddingRight = "";
	let savedScrollY = 0;

	function lockScroll() {
		if (scrollLocks++ > 0) return;
		savedScrollY = window.scrollY;
		savedOverflow = document.body.style.overflow;
		savedPaddingRight = document.body.style.paddingRight;
		/* Replace the scrollbar's width with padding, so locking does not shift
		   the page sideways under the fixed header. */
		const gap = window.innerWidth - document.documentElement.clientWidth;
		if (gap > 0) document.body.style.paddingRight = `${gap}px`;
		document.body.style.overflow = "hidden";
	}

	function releaseScroll() {
		if (scrollLocks === 0) return;
		if (--scrollLocks > 0) return;
		/* Restore exactly what was there, rather than assuming a default. */
		document.body.style.overflow = savedOverflow;
		document.body.style.paddingRight = savedPaddingRight;
		if (window.scrollY !== savedScrollY) window.scrollTo(0, savedScrollY);
	}

	const FOCUSABLE =
		'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';

	function initFloatingPanelHeader(root: HTMLElement) {
		/* Idempotent: a second boot over the same element is a no-op rather
		   than a second set of listeners on the same button. */
		if (root.dataset.fpReady === "") return;

		const bar = root.querySelector<HTMLElement>("[data-fp-bar]");
		const menu = root.querySelector<HTMLElement>("[data-fp-menu]");
		const burger = root.querySelector<HTMLButtonElement>("[data-fp-burger]");
		const items = Array.from(root.querySelectorAll<HTMLElement>("[data-fp-item]"));
		if (!bar) return;

		/* Enables every JS-driven state. Until it is set the CSS keeps panels on
		   hover and focus-within and, below the breakpoint, the navigation in
		   the flow — so nothing is unreachable if this never runs. */
		root.dataset.fpReady = "";

		const controller = new AbortController();
		const { signal } = controller;

		const threshold = Number(root.dataset.fpThreshold) || 0;
		const breakpoint = Number(root.dataset.fpBreakpoint) || 1024;
		const wide = window.matchMedia(`(min-width: ${breakpoint + 1}px)`);

		/* ---- scroll state ---- */
		let frame = 0;

		function applyScrollState() {
			const past = window.scrollY > threshold;
			if (past === root.hasAttribute("data-fp-scrolled")) return;
			root.toggleAttribute("data-fp-scrolled", past);
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				applyScrollState();
			});
		}

		window.addEventListener("scroll", schedule, { passive: true, signal });
		applyScrollState();

		/* ---- dropdown panels ---- */
		let openItem: HTMLElement | null = null;
		let closeTimer: ReturnType<typeof setTimeout> | undefined;

		function setPanel(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-fp-open", on);
				other
					.querySelector<HTMLButtonElement>("[data-fp-trigger]")
					?.setAttribute("aria-expanded", String(on));
			}
			openItem = item;
		}

		/* A short grace period, so the pointer can cross the gap between a
		   trigger and the panel below it without the panel vanishing. */
		function scheduleClose() {
			clearTimeout(closeTimer);
			closeTimer = setTimeout(() => setPanel(null), 220);
		}

		function cancelClose() {
			clearTimeout(closeTimer);
		}

		for (const item of items) {
			const trigger = item.querySelector<HTMLButtonElement>("[data-fp-trigger]");
			if (!trigger) continue;

			/* Hover opens at desktop widths, matching the reference. Guarded on
			   pointer type: a touch fires `pointerenter` immediately before the
			   click, which would otherwise open and then immediately close. */
			item.addEventListener(
				"pointerenter",
				(event) => {
					if (event.pointerType !== "mouse" || !wide.matches) return;
					cancelClose();
					setPanel(item);
				},
				{ signal },
			);

			item.addEventListener(
				"pointerleave",
				(event) => {
					if (event.pointerType !== "mouse" || !wide.matches) return;
					scheduleClose();
				},
				{ signal },
			);

			/* Enter and Space both reach this through a <button>'s native click. */
			trigger.addEventListener(
				"click",
				() => {
					cancelClose();
					setPanel(openItem === item ? null : item);
				},
				{ signal },
			);

			/* Tabbing out of the last link in a panel closes it, so focus never
			   sits behind an invisible layer. Not applied inside the sheet,
			   where the panel is an accordion in the flow and closing it under
			   a moving focus would collapse the list being read. */
			item.addEventListener(
				"focusout",
				(event) => {
					if (!wide.matches) return;
					const next = event.relatedTarget as Node | null;
					if (next && item.contains(next)) return;
					if (openItem === item) setPanel(null);
				},
				{ signal },
			);
		}

		/* ---- the sheet ---- */
		let sheetOpen = false;
		let lastFocused: HTMLElement | null = null;

		const sheetFocusables = () =>
			menu ? Array.from(menu.querySelectorAll<HTMLElement>(FOCUSABLE)) : [];

		function setSheet(next: boolean) {
			if (!burger || !menu || sheetOpen === next) return;
			sheetOpen = next;
			root.toggleAttribute("data-fp-sheet-open", next);
			burger.setAttribute("aria-expanded", String(next));

			if (next) {
				lastFocused = document.activeElement as HTMLElement | null;
				lockScroll();
				const first = sheetFocusables()[0];
				(first ?? menu).focus({ preventScroll: true });
			} else {
				releaseScroll();
				/* Any accordion left open inside a closed sheet would keep an
				   `aria-expanded="true"` on a control nobody can see. */
				setPanel(null);
				/* Focus returns to the control that opened it — but only if focus
				   is still inside the sheet, so a link the user followed is not
				   yanked back. */
				const active = document.activeElement as HTMLElement | null;
				if (!active || menu.contains(active) || active === document.body) {
					(lastFocused ?? burger).focus({ preventScroll: true });
				}
				lastFocused = null;
			}
		}

		/* Focusable only as a fallback target, when it contains nothing else. */
		menu?.setAttribute("tabindex", "-1");

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

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

		/* ---- shared dismissal ---- */
		document.addEventListener(
			"keydown",
			(event) => {
				if (event.key === "Escape") {
					if (openItem && wide.matches) {
						const trigger = openItem.querySelector<HTMLButtonElement>("[data-fp-trigger]");
						setPanel(null);
						trigger?.focus({ preventScroll: true });
						return;
					}
					if (sheetOpen) {
						event.preventDefault();
						setSheet(false);
						burger?.focus({ preventScroll: true });
					}
					return;
				}

				if (event.key !== "Tab" || !sheetOpen || !menu) return;

				/* Focus trap for the open sheet. The menu button is deliberately
				   part of the cycle: it is the control that closes the sheet, and
				   putting it out of reach would strand a keyboard user inside. */
				const cycle = sheetFocusables();
				if (burger) cycle.unshift(burger);
				if (cycle.length === 0) return;
				const first = cycle[0];
				const last = cycle[cycle.length - 1];
				const active = document.activeElement as HTMLElement | null;

				if (event.shiftKey && (active === first || !cycle.includes(active as HTMLElement))) {
					event.preventDefault();
					last.focus({ preventScroll: true });
				} else if (!event.shiftKey && active === last) {
					event.preventDefault();
					first.focus({ preventScroll: true });
				}
			},
			{ signal },
		);

		/*
		 * Outside-click. The open sheet's ground covers the viewport and is part
		 * of this root, so "outside the root" is not the test that closes it —
		 * "outside the bar" is. At desktop widths the two are the same thing.
		 */
		document.addEventListener(
			"pointerdown",
			(event) => {
				const target = event.target as Node;
				if (sheetOpen && !bar!.contains(target)) {
					setSheet(false);
					return;
				}
				if (root.contains(target)) return;
				setPanel(null);
			},
			{ signal },
		);

		/* ---- breakpoint ---- */
		function applyBreakpoint() {
			root.toggleAttribute("data-fp-mobile", !wide.matches);
			/* Crossing to desktop with the sheet open would otherwise leave the
			   body locked and a hidden sheet still flagged open. */
			if (wide.matches && sheetOpen) setSheet(false);
			if (!wide.matches && openItem) setPanel(null);
		}

		if (typeof wide.addEventListener === "function") {
			wide.addEventListener("change", applyBreakpoint, { signal });
		}

		applyBreakpoint();

		/*
		 * Astro replaces the document on a client-side navigation. Close first,
		 * so a sheet that was open does not leave the body locked on 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);
				setPanel(null);
				controller.abort();
				delete root.dataset.fpReady;
			},
			{ once: true, signal },
		);
	}

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

	/* 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>