Header — start here
Install as site header
The one to use for a header. It carries every file inline with its destination path, and tells the destination agent to inventory the existing routes and CSS before replacing anything — the parts that go wrong when a header meets a real layout.
Read it first
Install "Capsule header" from my Astro Component Library as this project's site header.
This is a header, not a page section. It replaces something that already exists, over routes that already exist, under CSS that already exists. Work through the steps in order and report as you go.
--- WHAT THIS HEADER IS ---
Component: Capsule header
Library page: https://astro.baysixmedia.com/components/capsule-header/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: fixed
Desktop dropdowns: no
Separate mobile menu: yes
Locks body scroll while open: yes
Astro client navigation: supported — it re-initialises on astro:page-load and detaches its listeners before each swap
Required files: 1
Dependencies: none — no npm packages, no CSS framework, no UI framework
Assets: none
Route data is passed through props. The prop interface above is the shape to map the destination's routes into.
Known integration notes for this header:
- Fixed, so it is out of the flow: the page below it needs top padding of at least topOffset plus the bar height, or the first heading renders underneath it.
- The expanded state is transparent by default and draws in expandedColor, so it is designed to sit over a dark hero. Over a light page set expandedColor to something dark, or give expandedBackground a fill.
- The capsule width is measured from the bar's own content. A very long brand name or six or more links will produce a capsule close to full width, which makes the contraction hard to see — that is a content decision, not a bug.
- backdrop-filter is used for the glass. Where it is unsupported the capsule still gets its fill, radius and colour change; only the blur is missing.
- Pass activePath={Astro.url.pathname} for aria-current on the active link; it is not inferred.
- The brand mark goes in the `brand-mark` named slot as an imported image or inline SVG. There is no prop that takes raw markup, and the component never uses set:html.
--- EXPECTED BEHAVIOUR ---
Desktop: every link is reachable in one tab pass, with no panels to open.
Mobile: a real toggle button with aria-expanded and aria-controls opens the small-screen menu, and the page behind it stops scrolling while it is open; crossing back to the desktop breakpoint while it is open resets it.
Accessibility: a single landmark <nav> with an accessible name, aria-current on the link for the current route, visible focus on every control, 44x44px minimum targets, full prefers-reduced-motion support, and a working no-JavaScript fallback. None of this may be removed while adapting the design.
--- STEPS ---
1. Inspect this project before editing anything. Find its Astro version, whether it uses <ClientRouter /> or view transitions, its global stylesheet, and its existing header. Report what you found before changing a file.
2. Find the real shared layout — the one every page actually renders through — and the current header inside it. Do not assume src/layouts/Layout.astro.
3. Inventory every current navigation route: read the existing header, src/pages/, and any route or nav-data file. List every label and href before touching anything.
4. Map those real routes into this component's navigation structure. Use the destination's own routes and labels. Do not ship the demo tree, and do not invent routes to fill out a panel that looks empty.
5. Preserve the destination's branding and approved copy: its brand name, wordmark, logo mark, button labels and tone. Adapt the component's colour and type props to the destination's tokens.
6. Identify global CSS in this project that targets header, nav, a, button, ul or body, and anything that sets overflow or transform on an ancestor of the header. Those are what break an installed header. Report the collisions and how you resolved each one.
7. Build the replacement alongside the existing header first. Remove the old one only once the new one renders and works on every route.
8. Remove the old header's JavaScript and CSS once it is gone — menu toggles, scroll listeners, body-scroll locks, media-query handlers and their stylesheets. Leaving them causes double body locks and phantom listeners.
9. Make sure exactly one header renders. Check for a second one in a nested layout, in an individual page, or left behind in the old markup.
10. Wire up positioning. It is position: fixed, so it is out of the flow entirely. The page below it must reserve its height, or the first heading renders underneath it. Check the reserved height at every breakpoint, because the header's own height changes with them.
11. Reserve the header's height on the page content so nothing starts underneath it, and re-check the reserved value at every breakpoint.
12. Preserve the skip link. If the project has one it must still be the first focusable element and must still land on the main content; if a fixed header would cover the target, add scroll-margin-top to it. If the project has no skip link, add one.
13. Leave analytics, meta tags, SEO, structured data and unrelated scripts in the layout exactly as they are. Removing a header is not a reason to touch anything else in <head>.
14. Make it work on a normal page load and, if this project uses client-side routing, across client navigation too: state reset between pages, no listeners accumulating, no body scroll lock surviving a navigation.
15. Visit every real route in this project and confirm the header renders, the correct link is marked current, and every href resolves. No 404s.
16. Test the whole site at 1440, 1280, 1024, 834, 390 and 320px: no horizontal overflow, no clipped panels, keyboard operation throughout, and the header never covering focused content.
--- FILES ---
Write each block below to the path in its header. Only the file marked (required) is needed to render; the supporting files are documentation and worked examples.
===== FILE (required): src/components/CapsuleHeader.astro =====
---
/**
* 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>
===== END src/components/CapsuleHeader.astro =====
===== FILE (supporting): src/components/CapsuleHeader.md =====
[read this one from the library page: src/components/library/CapsuleHeader.md]
--- 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.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.