Component librarylocal catalog
Back to catalog

Capsule header

A fixed site header that contracts into a centred, blurred capsule once the page scrolls, collapsing its brand word and tightening its spacing as it goes.

headernavfixedscrollcapsuleglassmobile-menu

Open preview in a new tab

Use this component

Four ways to get Capsule 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
“Capsule header” from my Astro Component Library:
https://astro.baysixmedia.com/components/capsule-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: Capsule header
Library page: https://astro.baysixmedia.com/components/capsule-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 Capsule header from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/capsule-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/CapsuleHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/CapsuleHeader.astrocomponentrequired
    • src/components/CapsuleHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * CapsuleHeader — a fixed site header that contracts into a centred, blurred
 * capsule once the page is scrolled, and reverses when it returns to the top.
 *
 * One self-contained file. No imports, no global stylesheet, no npm packages,
 * no webfonts, no images, no network requests: drop it into any Astro project
 * and render it. Static-only — no SSR, no endpoints, no cookies, no env.
 *
 * Design behaviour is inspired by the public CodaPress site header
 * (https://codapress.co.uk/), observed in a browser and rebuilt from
 * measurements. No source, markup, CSS, branding or artwork from that site is
 * used here. See CapsuleHeader.md.
 *
 * The morph, measured from the reference at 1440px and reproduced as CSS
 * transitions rather than scripted per-frame styles:
 *
 *   expanded   full width, transparent, square corners, no blur, wide nav
 *              spacing, brand word visible
 *   capsule    content width, translucent fill, pill radius, backdrop blur,
 *              tightened nav spacing, brand word collapsed to nothing
 *   trigger    scrollY > 10 (configurable), reversible at the same point
 *
 * The one thing CSS cannot do alone is "shrink to the width of my own
 * content": there is no transitionable keyword for it that is safe across
 * browsers yet. So the controller measures that width once, publishes it as a
 * custom property, and the transition runs between two plain lengths. Every
 * other property in the morph is pure CSS.
 *
 * At and below `breakpoint` the morph is switched off entirely and the header
 * is an ordinary full-width bar with a menu button and an overlay 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 at all — it could not open
 * anything — and the link list is shown in place instead.
 *
 * 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.
 */

export interface CapsuleHeaderLink {
	/** Visible text. A link without one is dropped. */
	label: string;
	/** Destination. Unsafe or missing values render as plain text, never a link. */
	href: string;
	/** Opens in a new tab, with the matching rel. */
	external?: boolean;
	/** Overrides the accessible name when the label alone is not enough. */
	ariaLabel?: string;
}

export interface CapsuleHeaderProps {
	/** Brand word. Also the accessible name of the brand link. */
	brandName: string;
	/** Where the brand links to. Omit for a non-linked wordmark. */
	brandHref?: string;
	/** The navigation links. */
	links: CapsuleHeaderLink[];
	/** Pixels of scroll before the bar contracts. The reference uses 10. */
	scrollThreshold?: number;
	/** Width at or below which the morph is off and the menu button appears. */
	breakpoint?: number;
	/** Distance from the top of the viewport. */
	topOffset?: string;
	/** Fill at the top of the page. Transparent in the reference. */
	expandedBackground?: string;
	/** Fill once contracted. Translucent, so the blur behind it reads. */
	capsuleBackground?: string;
	/** Text colour at the top of the page. */
	expandedColor?: string;
	/** Text colour once contracted, against `capsuleBackground`. */
	capsuleColor?: string;
	/** Backdrop blur in the capsule state. `0px` turns the glass off. */
	blur?: string;
	/** Marks the matching link `aria-current="page"`. Compared after trimming. */
	activePath?: string;
	/** Accessible name for the small-screen menu button. */
	menuLabel?: string;
}

interface Props extends CapsuleHeaderProps {
	class?: string;
	id?: string;
}

const {
	brandName,
	brandHref,
	links,
	scrollThreshold = 10,
	breakpoint = 768,
	topOffset = "10px",
	expandedBackground = "transparent",
	capsuleBackground = "rgb(255 255 255 / 72%)",
	expandedColor = "#ffffff",
	capsuleColor = "#151515",
	blur = "12px",
	activePath,
	menuLabel = "Menu",
	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 activePath === "string" ? normalisePath(activePath) : undefined;

/* A malformed entry is dropped rather than rendered as an empty target. */
const items = (Array.isArray(links) ? links : [])
	.filter(
		(link): link is CapsuleHeaderLink =>
			!!link && typeof link.label === "string" && link.label.trim() !== "",
	)
	.map((link) => {
		const href = safeHref(link.href);
		return {
			label: link.label.trim(),
			href,
			external: href !== undefined && link.external === true,
			ariaLabel: typeof link.ariaLabel === "string" && link.ariaLabel.trim() !== ""
				? link.ariaLabel.trim()
				: undefined,
			isCurrent: href !== undefined && current !== undefined && normalisePath(href) === current,
		};
	});

const homeHref = safeHref(brandHref);

/* A required prop, but a build-time throw here would take the whole page with
   it, so a non-string is coerced rather than trusted. */
const brandLabel = typeof brandName === "string" ? brandName.trim() : "";
const brandInitial = brandLabel.charAt(0) || "•";

/* 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 ?? `ch-${Math.random().toString(36).slice(2, 9)}`;
const sheetId = `${uid}-sheet`;
const navId = `${uid}-nav`;

/*
 * 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.
 * That means the value is HTML-escaped on the way out, and a `<style>`'s
 * contents are raw text that the parser does not decode — so an escaped
 * character would survive into the CSS as an entity and break the rule it
 * sits in. The string below is therefore 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 non-negative
 * number. Nothing else reaches the stylesheet.
 */
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "");
const cssUid = /^[A-Za-z]/.test(safeUid) ? safeUid : `ch-${safeUid || "instance"}`;
const bp = Math.max(0, Number(breakpoint) || 0) || 768;

/*
 * What the fallback has to achieve, and why each line is here:
 *
 *   above the breakpoint  the inline nav is the navigation, so the sheet is
 *                         hidden — otherwise both render and the page has two
 *                         copies of every link
 *   at or below it        the inline nav is hidden and the sheet becomes the
 *                         navigation, shown in place beneath the bar
 *
 * The menu button is deliberately not shown in either branch. 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-ch-mobile]`, which the controller sets, brings it back.
 */
const fallbackCss = `
[data-ch-uid=${cssUid}]:not([data-ch-ready]) .ch-sheet{display:none}
@media (max-width:${bp}px){
[data-ch-uid=${cssUid}]:not([data-ch-ready]) .ch-nav{display:none}
[data-ch-uid=${cssUid}]:not([data-ch-ready]) .ch-sheet{display:block}
}`;

const styleVars = [
	`--ch-top:${topOffset}`,
	`--ch-bg-expanded:${expandedBackground}`,
	`--ch-bg-capsule:${capsuleBackground}`,
	`--ch-ink-expanded:${expandedColor}`,
	`--ch-ink-capsule:${capsuleColor}`,
	`--ch-blur:${blur}`,
].join(";");

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

<header
	class:list={["capsule-header", className]}
	id={id}
	data-header-root
	data-ch-root
	data-ch-threshold={String(Math.max(0, Number(scrollThreshold) || 0))}
	data-ch-breakpoint={String(bp)}
	data-ch-uid={cssUid}
	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-ch-ready`.

	    `set:text` rather than a raw-HTML directive — a style element's text
	    content is its stylesheet, so nothing here needs a raw-HTML channel.
	*/}
	<style is:inline set:text={fallbackCss}></style>
	{/*
	    The wrapper is the fixed positioning context and spans the viewport, so
	    it must not swallow clicks on the page beneath it. Pointer events are
	    turned off here and back on for the bar and the sheet.
	*/}
	<div class="ch-bar" data-ch-bar>
		<Brand
			class="ch-brand"
			href={homeHref}
			aria-label={homeHref ? `${brandLabel} home` : undefined}
		>
			{/*
			    Separate wrappers, so the word can collapse on its own while the
			    mark stays put. The mark is decorative: the word is inside the
			    link and remains in the accessibility tree even when it is
			    visually collapsed, so the link keeps its name throughout.
			*/}
			<span class="ch-mark" aria-hidden="true">
				<slot name="brand-mark">
					<span class="ch-mark-fallback">{brandInitial}</span>
				</slot>
			</span>
			<span class="ch-word" data-ch-word><span class="ch-word-text">{brandLabel}</span></span>
		</Brand>

		<nav class="ch-nav" id={navId} aria-label={menuLabel}>
			<ul class="ch-list">
				{
					items.map((item) => (
						<li class="ch-item">
							{item.href ? (
								<a
									class="ch-link"
									href={item.href}
									aria-label={item.ariaLabel}
									aria-current={item.isCurrent ? "page" : undefined}
									target={item.external ? "_blank" : undefined}
									rel={item.external ? "noopener noreferrer" : undefined}
								>
									{item.label}
								</a>
							) : (
								<span class="ch-link ch-link-inert">{item.label}</span>
							)}
						</li>
					))
				}
			</ul>
		</nav>

		<button
			class="ch-burger"
			type="button"
			data-ch-burger
			aria-expanded="false"
			aria-controls={sheetId}
		>
			<span class="ch-burger-label">{menuLabel}</span>
			<span class="ch-bars" aria-hidden="true">
				<span class="ch-bar-line"></span>
				<span class="ch-bar-line"></span>
			</span>
		</button>
	</div>

	{/*
	    The small-screen sheet. It is a sibling of the bar rather than a child,
	    so the bar can stay a capsule while the sheet fills the screen. Without
	    JavaScript it is a plain block in the flow under the bar, so every link
	    is still reachable.
	*/}
	<div class="ch-sheet" id={sheetId} data-ch-sheet>
		<nav class="ch-sheet-nav" aria-label={menuLabel}>
			<ul class="ch-sheet-list">
				{
					items.map((item) => (
						<li>
							{item.href ? (
								<a
									class="ch-sheet-link"
									href={item.href}
									aria-label={item.ariaLabel}
									aria-current={item.isCurrent ? "page" : undefined}
									target={item.external ? "_blank" : undefined}
									rel={item.external ? "noopener noreferrer" : undefined}
								>
									{item.label}
								</a>
							) : (
								<span class="ch-sheet-link ch-link-inert">{item.label}</span>
							)}
						</li>
					))
				}
			</ul>
		</nav>
	</div>
</header>

<style>
	.capsule-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. */
		--ch-top: 10px;
		--ch-gutter: 10px;
		--ch-max: 1420px;
		--ch-height: 60px;
		--ch-pad-expanded: 19px;
		--ch-pad-capsule: 28px;
		--ch-gap-expanded: 34px;
		--ch-gap-capsule: 17px;
		--ch-radius-capsule: 500px;
		--ch-bg-expanded: transparent;
		--ch-bg-capsule: rgb(255 255 255 / 72%);
		--ch-ink-expanded: #ffffff;
		--ch-ink-capsule: #151515;
		--ch-blur: 12px;
		--ch-ring: currentColor;
		--ch-z: 50;
		--ch-duration: 420ms;
		--ch-ease: cubic-bezier(0.22, 1, 0.36, 1);
		--ch-font:
			ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
			"Helvetica Neue", Arial, sans-serif;

		/* Measured by the controller and rewritten on resize. The fallback is
		   what renders before the script runs and if it never runs at all. */
		--ch-capsule-w: auto;

		position: fixed;
		inset-inline: 0;
		top: var(--ch-top);
		z-index: var(--ch-z);
		box-sizing: border-box;
		display: flex;
		flex-direction: column;
		align-items: center;
		/* The wrapper spans the viewport; only its children take clicks. */
		pointer-events: none;
		font-family: var(--ch-font);
		line-height: 1.4;
	}

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

	/* ---------- the morphing bar ---------- */

	.ch-bar {
		pointer-events: auto;
		box-sizing: border-box;
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		width: min(var(--ch-max), 100% - var(--ch-gutter) * 2);
		min-height: var(--ch-height);
		padding-inline: var(--ch-pad-expanded);
		border-radius: 0;
		background: var(--ch-bg-expanded);
		color: var(--ch-ink-expanded);
		/* Declared at rest as well as in the capsule state so the blur value
		   animates rather than switching on at full strength. */
		backdrop-filter: blur(0px);
		-webkit-backdrop-filter: blur(0px);
		transition:
			width var(--ch-duration) var(--ch-ease),
			padding-inline var(--ch-duration) var(--ch-ease),
			border-radius var(--ch-duration) var(--ch-ease),
			background-color var(--ch-duration) var(--ch-ease),
			color var(--ch-duration) var(--ch-ease),
			backdrop-filter var(--ch-duration) var(--ch-ease),
			-webkit-backdrop-filter var(--ch-duration) var(--ch-ease);
	}

	/*
	 * The capsule. `--ch-capsule-w` is a plain length written by the
	 * controller, so this is a length-to-length transition in every browser —
	 * no reliance on interpolating to a keyword.
	 *
	 * Only applied once the controller has measured, so a page that never runs
	 * the script keeps the expanded bar rather than collapsing to `auto`.
	 */
	[data-ch-ready][data-ch-capsule] .ch-bar {
		width: var(--ch-capsule-w);
		padding-inline: var(--ch-pad-capsule);
		border-radius: var(--ch-radius-capsule);
		background: var(--ch-bg-capsule);
		color: var(--ch-ink-capsule);
		backdrop-filter: blur(var(--ch-blur));
		-webkit-backdrop-filter: blur(var(--ch-blur));
	}

	/*
	 * The measuring pass: the bar is laid out at its natural capsule width for
	 * a single synchronous read, then put back before anything paints.
	 *
	 * Transitions are suppressed on all three elements, not just the bar. With
	 * them left on, the word and the nav gap would still be animating from
	 * their expanded values when the read happened, and the measurement would
	 * come back as the expanded width — which is exactly the bug this comment
	 * replaced.
	 */
	[data-ch-measuring] .ch-bar {
		width: max-content;
		padding-inline: var(--ch-pad-capsule);
		visibility: hidden;
	}

	[data-ch-measuring] .ch-word {
		grid-template-columns: 0fr;
		margin-inline-start: 0;
	}

	[data-ch-measuring] .ch-list {
		gap: var(--ch-gap-capsule);
	}

	[data-ch-measuring] .ch-bar,
	[data-ch-measuring] .ch-word,
	[data-ch-measuring] .ch-list {
		transition: none !important;
	}

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

	.ch-brand {
		display: inline-flex;
		align-items: center;
		min-height: 44px;
		color: inherit;
		text-decoration: none;
		flex: 0 0 auto;
	}

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

	/* 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. */
	.ch-mark > :global(svg),
	.ch-mark > :global(img) {
		display: block;
		width: 100%;
		height: 100%;
	}

	/*
	 * The text fallback, used when no `brand-mark` slot is supplied: the
	 * brand's initial in a ruled square. Drawn in `currentColor` against no
	 * fill, so it stays legible in both the expanded and capsule states
	 * without knowing either background.
	 */
	.ch-mark-fallback {
		display: grid;
		place-items: center;
		width: 100%;
		height: 100%;
		border: 1.5px solid currentColor;
		border-radius: 7px;
		font-size: 0.75rem;
		font-weight: 700;
		line-height: 1;
		text-transform: uppercase;
	}

	/*
	 * The collapsing word.
	 *
	 * `grid-template-columns: 1fr -> 0fr` is the one width collapse that
	 * animates reliably without a measured pixel value. The child clips, so
	 * the text is squeezed out rather than reflowed.
	 *
	 * Deliberately not `display: none`, `visibility: hidden` or `aria-hidden`:
	 * the word is the brand link's accessible name, and it stays in the
	 * accessibility tree in both states. Only its painted size changes.
	 */
	.ch-word {
		display: grid;
		grid-template-columns: 1fr;
		margin-inline-start: 8px;
		opacity: 1;
		transition:
			grid-template-columns var(--ch-duration) var(--ch-ease),
			margin-inline-start var(--ch-duration) var(--ch-ease),
			opacity calc(var(--ch-duration) * 0.6) var(--ch-ease);
	}

	.ch-word-text {
		min-width: 0;
		overflow: hidden;
		white-space: nowrap;
		font-weight: 600;
		font-size: 1rem;
		letter-spacing: -0.01em;
	}

	[data-ch-ready][data-ch-capsule] .ch-word {
		grid-template-columns: 0fr;
		margin-inline-start: 0;
		opacity: 0;
	}

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

	.ch-nav {
		display: flex;
		min-width: 0;
	}

	.ch-list {
		display: flex;
		align-items: center;
		gap: var(--ch-gap-expanded);
		margin: 0;
		padding: 0;
		list-style: none;
		transition: gap var(--ch-duration) var(--ch-ease);
	}

	[data-ch-ready][data-ch-capsule] .ch-list {
		gap: var(--ch-gap-capsule);
	}

	.ch-item {
		display: flex;
	}

	.ch-link {
		display: inline-flex;
		align-items: center;
		min-height: 44px;
		color: inherit;
		text-decoration: none;
		font-size: 0.9375rem;
		white-space: nowrap;
		transition: opacity 200ms ease;
	}

	.ch-link:hover {
		opacity: 0.7;
	}

	.ch-link-inert {
		cursor: default;
		opacity: 0.6;
	}

	.ch-link[aria-current="page"] {
		font-weight: 600;
		text-decoration: underline;
		text-underline-offset: 5px;
	}

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

	.ch-burger {
		display: none;
		align-items: center;
		justify-content: center;
		flex: 0 0 auto;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 10px;
		background: transparent;
		color: inherit;
		font: inherit;
		cursor: pointer;
	}

	/* Visible to assistive technology, not painted — the bars carry the
	   meaning visually and this carries it to a screen reader. */
	.ch-burger-label {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
		border: 0;
	}

	.ch-bars {
		display: grid;
		gap: 5px;
		width: 20px;
	}

	.ch-bar-line {
		display: block;
		height: 2px;
		border-radius: 2px;
		background: currentColor;
		transition:
			transform 260ms var(--ch-ease),
			opacity 200ms ease;
	}

	[data-ch-open] .ch-bar-line:first-child {
		transform: translateY(3.5px) rotate(45deg);
	}

	[data-ch-open] .ch-bar-line:last-child {
		transform: translateY(-3.5px) rotate(-45deg);
	}

	/* ---------- mobile sheet ---------- */

	.ch-sheet {
		pointer-events: auto;
		box-sizing: border-box;
		/* Without the script this is a plain block under the bar, so every
		   link is reachable with JavaScript disabled. */
		width: min(var(--ch-max), 100% - var(--ch-gutter) * 2);
		margin-top: 8px;
		padding: 8px;
		border-radius: 18px;
		background: var(--ch-bg-capsule);
		color: var(--ch-ink-capsule);
	}

	/*
	 * Without the controller the sheet *is* the navigation on small screens,
	 * so it has to behave like a document element rather than an overlay: it
	 * sits beneath the bar, it scrolls itself if it is taller than the space
	 * available, and it never touches the page's own scrolling. The links are
	 * ordinary anchors, so they are keyboard reachable with nothing else
	 * needed.
	 */
	.capsule-header:not([data-ch-ready]) .ch-sheet {
		max-height: calc(100vh - var(--ch-top) - var(--ch-height) - var(--ch-gutter) - 16px);
		max-height: calc(100dvh - var(--ch-top) - var(--ch-height) - var(--ch-gutter) - 16px);
		overflow-y: auto;
	}

	/* Once the controller is running the sheet is an overlay, hidden until
	   opened. `display: none` keeps its links out of the tab order. */
	[data-ch-ready] .ch-sheet {
		display: none;
		position: fixed;
		inset: calc(var(--ch-top) + var(--ch-height) + 8px) var(--ch-gutter) var(--ch-gutter);
		width: auto;
		margin-top: 0;
		padding: 12px;
		overflow-y: auto;
		backdrop-filter: blur(var(--ch-blur));
		-webkit-backdrop-filter: blur(var(--ch-blur));
	}

	[data-ch-ready][data-ch-open] .ch-sheet {
		display: block;
		animation: ch-sheet-in 240ms var(--ch-ease) both;
	}

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

	.ch-sheet-list {
		display: flex;
		flex-direction: column;
		gap: 2px;
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.ch-sheet-link {
		display: flex;
		align-items: center;
		min-height: 48px;
		padding-inline: 12px;
		border-radius: 12px;
		color: inherit;
		text-decoration: none;
		font-size: 1.0625rem;
	}

	.ch-sheet-link:hover {
		background: rgb(0 0 0 / 6%);
	}

	.ch-sheet-link[aria-current="page"] {
		font-weight: 600;
	}

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

	.ch-brand:focus-visible,
	.ch-link:focus-visible,
	.ch-burger:focus-visible,
	.ch-sheet-link:focus-visible {
		outline: 2px solid var(--ch-ring);
		outline-offset: 3px;
		border-radius: 6px;
	}

	/* ---------- small screens ----------
	   The breakpoint is a prop, so the controller mirrors it onto the root as
	   `data-ch-mobile` and the layout keys off that attribute rather than a
	   media query, which could not see the prop. Verified at breakpoint 900:
	   mobile at 850 and 900, desktop at 901 and 950, with CSS and controller
	   agreeing at each.

	   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-ch-mobile] .ch-nav {
		display: none;
	}

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

	/* Mobile is never a capsule: full width, square, no blur, no collapse. */
	[data-ch-mobile] .ch-bar {
		width: min(var(--ch-max), 100% - var(--ch-gutter) * 2);
		padding-inline: var(--ch-pad-expanded);
		border-radius: 0;
		background: var(--ch-bg-expanded);
		color: var(--ch-ink-expanded);
		backdrop-filter: blur(0px);
		-webkit-backdrop-filter: blur(0px);
	}

	[data-ch-mobile] .ch-word {
		grid-template-columns: 1fr;
		margin-inline-start: 8px;
		opacity: 1;
	}

	/* Over about 1280px the reference keeps a small side gutter rather than
	   letting the bar reach the edge; below that the gutter is what it is. */
	@media (min-width: 1280px) {
		.capsule-header {
			--ch-gutter: 20px;
		}
	}

	/* ---------- 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) {
		.capsule-header {
			--ch-duration: 1ms;
		}

		.ch-bar-line,
		.ch-link {
			transition-duration: 1ms;
		}

		[data-ch-ready][data-ch-open] .ch-sheet {
			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 initCapsuleHeader(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.chReady === "") return;

		const bar = root.querySelector<HTMLElement>("[data-ch-bar]");
		const burger = root.querySelector<HTMLButtonElement>("[data-ch-burger]");
		const sheet = root.querySelector<HTMLElement>("[data-ch-sheet]");
		if (!bar) return;

		root.dataset.chReady = "";

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

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

		/* ---- capsule width ----------------------------------------------
		 * The one measurement. The bar is laid out at its natural capsule
		 * width for a single frame — hidden, transitions off — and the result
		 * is published as a length the CSS can transition to.
		 *
		 * Re-measured whenever the things that decide it change: the viewport,
		 * the bar's own contents, and web fonts finishing loading.
		 */
		let measuring = false;

		function measure() {
			if (measuring || !wide.matches) return;
			measuring = true;
			const wasCapsule = root.hasAttribute("data-ch-capsule");
			root.setAttribute("data-ch-measuring", "");
			root.removeAttribute("data-ch-capsule");
			/* Reading the width forces layout with the measuring styles on. */
			const width = Math.ceil(bar!.getBoundingClientRect().width);
			root.removeAttribute("data-ch-measuring");
			if (wasCapsule) root.setAttribute("data-ch-capsule", "");
			if (width > 0) root.style.setProperty("--ch-capsule-w", `${width}px`);
			measuring = false;
		}

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

		function applyScrollState() {
			/* Below the breakpoint the morph is off entirely, so the attribute
			   is removed rather than left stale behind a media query. */
			if (!wide.matches) {
				root.removeAttribute("data-ch-capsule");
				return;
			}
			const past = window.scrollY > threshold;
			if (past === root.hasAttribute("data-ch-capsule")) return;
			root.toggleAttribute("data-ch-capsule", past);
		}

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

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

		/* ---- mobile sheet ---- */
		let open = false;
		let lastFocused: HTMLElement | null = null;

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

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

			if (next) {
				lastFocused = document.activeElement as HTMLElement | null;
				lockScroll();
				/* Focus moves into the sheet, to its first link if it has one. */
				const first = sheetFocusables()[0];
				(first ?? sheet).focus({ preventScroll: true });
			} else {
				releaseScroll();
				/* 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 || sheet.contains(active) || active === document.body) {
					(lastFocused ?? burger).focus({ preventScroll: true });
				}
				lastFocused = null;
			}
		}

		/* The sheet itself is focusable only while open, as a fallback target
		   when it contains nothing focusable. */
		sheet?.setAttribute("tabindex", "-1");

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

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

		/* Escape closes wherever focus happens to be — a sheet that covers the
		   page has to be dismissable without hunting for the button. */
		document.addEventListener(
			"keydown",
			(event) => {
				if (!open) return;
				if (event.key === "Escape") {
					event.preventDefault();
					setOpen(false);
					burger?.focus({ preventScroll: true });
					return;
				}
				if (event.key !== "Tab" || !sheet) return;

				/* Focus trap. The burger 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 },
		);

		/* A tap outside the sheet and outside the bar closes it. */
		document.addEventListener(
			"pointerdown",
			(event) => {
				if (!open) return;
				if (root.contains(event.target as Node)) return;
				setOpen(false);
			},
			{ signal },
		);

		/* ---- breakpoint ---- */
		function applyBreakpoint() {
			root.toggleAttribute("data-ch-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 && open) setOpen(false);
			applyScrollState();
			measure();
		}

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

		/*
		 * Re-measure on viewport resize and after fonts settle — the two things
		 * that genuinely change the capsule's natural width.
		 *
		 * Deliberately *not* a ResizeObserver on the bar. The bar's own size
		 * changes on every morph, so observing it made the morph re-enter the
		 * measuring pass on each animated frame: the bar blinked to
		 * `visibility: hidden` repeatedly and came to rest holding the
		 * measuring pass's padding instead of its own. Observing the cause of a
		 * change from inside the change is a loop, not a safeguard.
		 *
		 * The cost is that a runtime content change with no resize — swapping
		 * the brand name from script, say — will not re-measure on its own.
		 * That is documented, and `measure()` is idempotent if a host wants to
		 * trigger one by dispatching a resize.
		 */
		let resizeFrame = 0;

		window.addEventListener(
			"resize",
			() => {
				if (resizeFrame) return;
				resizeFrame = requestAnimationFrame(() => {
					resizeFrame = 0;
					measure();
				});
			},
			{ passive: true, signal },
		);

		applyBreakpoint();

		/* Fonts change text metrics, which changes the capsule width. */
		document.fonts?.ready.then(() => {
			if (!signal.aborted) measure();
		});

		/*
		 * 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);
				if (resizeFrame) cancelAnimationFrame(resizeFrame);
				setOpen(false);
				controller.abort();
				delete root.dataset.chReady;
			},
			{ once: true, signal },
		);
	}

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

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