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. Omitted, with no
* `brand-mark` slot either, no brand block renders. */
brandName?: string;
/** Where the brand links to. Omit for a non-linked wordmark. */
brandHref?: string;
/** The navigation links. Empty, no nav, menu button or sheet renders. */
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) || "•";
/* Nothing is invented when data is missing: no brand word and no mark, no
brand block; no links, no nav, menu button or sheet; neither, no header. */
const hasBrand = brandLabel !== "" || Astro.slots.has("brand-mark");
const hasLinks = items.length > 0;
const hasContent = hasBrand || hasLinks;
/* 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";
---
{hasContent && (
<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>
{hasBrand && (
<Brand
class="ch-brand"
href={homeHref}
aria-label={homeHref ? (brandLabel ? `${brandLabel} home` : "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>
{brandLabel && (
<span class="ch-word" data-ch-word><span class="ch-word-text">{brandLabel}</span></span>
)}
</Brand>
)}
{hasLinks && (
<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>
)}
{hasLinks && (
<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.
*/}
{hasLinks && (
<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 =====
# Capsule header
A fixed site header that is nearly full width and transparent at the top of the
page, then contracts into a centred, blurred capsule once you scroll — pill
radius, translucent fill, tightened navigation spacing, and the brand word
collapsing away while the brand mark stays. Scroll back to the top and the
whole thing reverses.
At and below the breakpoint — 768px by default, and genuinely configurable —
the contraction is switched off entirely and it becomes an ordinary full-width
bar with a menu button and an accessible overlay sheet.
Catalog page: `/components/capsule-header`
## Purpose
A header for a site whose first screen is a full-bleed hero: transparent over
the hero, and a compact, legible capsule over everything after it. The point is
that it stops being a bar and becomes an object, without the page ever moving.
> **Design credit.** The behaviour was inspired by the public
> [CodaPress](https://codapress.co.uk/) site header, which was observed in a
> browser at several widths and scroll positions and rebuilt from those
> measurements. **No source code, markup, CSS, script, branding, logo or
> artwork from that site is used here.** The reference drives its morph with
> per-frame inline styles from a JavaScript animation library; this component
> reaches the same end states with CSS transitions and about 150 lines of
> dependency-free TypeScript. Differences are listed under
> [Measurable differences from the reference](#measurable-differences-from-the-reference).
## Files required
**`CapsuleHeader.astro`** — one file, the whole component.
- No imports — not from this catalog, not from anywhere.
- No companion files, no npm packages, no Astro built-ins.
- No global stylesheet. Every colour, size, breakpoint, duration and easing it
needs is declared on its own root element as a `--ch-*` custom property, so
it inherits nothing from the host page and leaks nothing back.
- No Tailwind, no CSS reset, no utility classes, no framework.
- No webfonts, no images, no network requests of any kind.
- The only assumption about the host is that Astro compiles `.astro` files.
`CapsuleHeader.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import CapsuleHeader from "../components/global/CapsuleHeader.astro";
const navigation = [
{ label: "Services", href: "/services/" },
{ label: "Industries", href: "/industries/" },
{ label: "About", href: "/about/" },
{ label: "Contact", href: "/contact/" },
];
---
<CapsuleHeader
brandName="Northstar"
brandHref="/"
links={navigation}
activePath={Astro.url.pathname}
/>
```
## Custom logo usage
The brand mark goes in the `brand-mark` named slot as an inline SVG or an
imported image. There is no prop that takes markup, and the component never
uses `set:html`.
```astro
---
import CapsuleHeader from "../components/global/CapsuleHeader.astro";
import mark from "../assets/mark.svg";
---
<CapsuleHeader brandName="Northstar" brandHref="/" links={navigation}>
<svg slot="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="currentColor" />
<path d="M9 16h14M16 9v14" stroke="white" stroke-width="2" />
</svg>
</CapsuleHeader>
<!-- or an imported image -->
<CapsuleHeader brandName="Northstar" links={navigation}>
<img slot="brand-mark" src={mark.src} alt="" width="24" height="24" />
</CapsuleHeader>
```
The slot is sized to 24×24 by the component, so a mark lands in the same place
as the fallback without having to know the number. An inline SVG using
`fill="currentColor"` or `stroke="currentColor"` recolours itself as the header
crosses between its expanded and capsule inks.
**With no slot** you get a text fallback: the brand's first letter in a ruled
square, drawn in `currentColor`, which stays legible in both states without
knowing either background.
## Props
`brandName` and `links` carry no defaults — the component never invents a
brand or a route. Everything else has a default. Each block renders only when
its data is present: no brand word and no `brand-mark` slot, no brand block; no
links, no nav, menu button or sheet; neither, the component renders nothing.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brandName` | `string?` | — | Brand word. Also the accessible name of the brand link, and the source of the fallback mark's letter. With only a `brand-mark` slot, the link is named "Home". |
| `brandHref` | `string?` | — | Where the brand links to. Omitted or unsafe renders a non-linked wordmark. |
| `links` | `CapsuleHeaderLink[]?` | — | The navigation links. |
| `scrollThreshold` | `number` | `10` | Pixels of scroll before it contracts. Reversal happens at the same point. |
| `breakpoint` | `number` | `768` | At and below this width the contraction is off and the menu button appears. Drives the controller, the CSS layout and the no-JS fallback alike. |
| `topOffset` | `string` | `"10px"` | Distance from the top of the viewport. |
| `expandedBackground` | `string` | `"transparent"` | Fill at the top of the page. |
| `capsuleBackground` | `string` | `"rgb(255 255 255 / 72%)"` | Fill once contracted. Translucent, so the blur reads. |
| `expandedColor` | `string` | `"#ffffff"` | Text colour at the top. The default assumes a dark hero. |
| `capsuleColor` | `string` | `"#151515"` | Text colour once contracted. |
| `blur` | `string` | `"12px"` | Backdrop blur in the capsule state. `"0px"` turns the glass off. |
| `activePath` | `string?` | — | Marks the matching link `aria-current="page"`. Compared with query, hash and trailing slashes removed. |
| `menuLabel` | `string` | `"Menu"` | Accessible name for the menu button and both `<nav>` landmarks. |
| `class` | `string?` | — | Extra class on the `<header>`. |
| `id` | `string?` | — | Header id. Also seeds the sheet's id instead of a random one. |
### `CapsuleHeaderLink`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | Visible text. An entry without one is dropped. |
| `href` | `string` | Destination. An unsafe or missing value renders as plain text, never a link. |
| `external` | `boolean?` | Opens in a new tab with `rel="noopener noreferrer"`. |
| `ariaLabel` | `string?` | Overrides the accessible name where the label alone is not enough. |
### Link data is validated
Link data often comes from a CMS or a config file, and a header renders on
every route, so a bad `href` would be a bad `href` site-wide. Every value is
checked before it becomes an attribute:
- **Allowed:** root-relative (`/about/`), bare relative (`about/`), fragments
(`#section`), `./` and `../`, and `http:`, `https:`, `mailto:`, `tel:`.
- **Refused:** everything else, including `javascript:`, `data:` and `vbscript:`
in any casing, with leading whitespace, or with control characters inserted
mid-scheme (`java	script:`). Control characters are stripped before the
check, so that trick does not get past it.
- A refused `href` renders the label as **plain text** rather than a link —
visible and inert, instead of silently disappearing.
- Entries that are `null`, or have no label, are dropped.
- `brandHref` goes through the same check; a refused one renders the wordmark
as a `<span>`.
Verified against `javascript:`, `JaVaScRiPt:`, ` javascript:`,
`java<TAB>script:` and a `data:text/html` payload: all five rendered as text,
no dialog fired, and no unsafe attribute reached the DOM.
## Named slots
| Slot | What goes in it |
| :--- | :--- |
| `brand-mark` | An inline SVG or `<img>` for the brand mark. Sized to 24×24. Falls back to the brand initial in a ruled square. |
There is no default slot: a header's contents are its brand and its links, and
both are props.
## CSS custom properties
Set any of these on the element — with `class`, an `id`, or a wrapper rule — to
retune it without touching the file. The props above are shorthands for the
common six.
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--ch-top` | `10px` | Distance from the top of the viewport |
| `--ch-gutter` | `10px` (`20px` ≥1280) | Space between the expanded bar and the viewport edge |
| `--ch-max` | `1420px` | Maximum expanded width |
| `--ch-height` | `60px` | Minimum bar height, constant across both states |
| `--ch-pad-expanded` | `19px` | Inline padding at the top of the page |
| `--ch-pad-capsule` | `28px` | Inline padding once contracted |
| `--ch-gap-expanded` | `34px` | Navigation spacing at the top |
| `--ch-gap-capsule` | `17px` | Navigation spacing once contracted |
| `--ch-radius-capsule` | `500px` | Capsule corner radius |
| `--ch-bg-expanded` | `transparent` | Expanded fill (prop: `expandedBackground`) |
| `--ch-bg-capsule` | `rgb(255 255 255 / 72%)` | Capsule fill (prop: `capsuleBackground`) |
| `--ch-ink-expanded` | `#ffffff` | Expanded text (prop: `expandedColor`) |
| `--ch-ink-capsule` | `#151515` | Capsule text (prop: `capsuleColor`) |
| `--ch-blur` | `12px` | Capsule backdrop blur (prop: `blur`) |
| `--ch-ring` | `currentColor` | Focus ring colour |
| `--ch-z` | `50` | Stacking order |
| `--ch-duration` | `420ms` | Morph duration |
| `--ch-ease` | `cubic-bezier(0.22, 1, 0.36, 1)` | Morph easing |
| `--ch-font` | system sans stack | Font stack |
`--ch-capsule-w` is written by the controller and should not be set by hand.
## How the animation works
Every property in the morph is a CSS transition on a `[data-ch-capsule]`
attribute that a small controller toggles. There is no per-frame scripting, no
`requestAnimationFrame` loop driving styles, and no animation library.
| | Expanded | Capsule |
| :--- | :--- | :--- |
| width | `min(--ch-max, 100% - 2×gutter)` | `var(--ch-capsule-w)` |
| border-radius | `0` | `500px` |
| background | `transparent` | `rgb(255 255 255 / 72%)` |
| color | `--ch-ink-expanded` | `--ch-ink-capsule` |
| backdrop-filter | `blur(0px)` | `blur(12px)` |
| padding-inline | `19px` | `28px` |
| nav gap | `34px` | `17px` |
| brand word | `grid-template-columns: 1fr`, opacity 1 | `0fr`, opacity 0 |
Two details are worth knowing:
**The measured width.** CSS cannot transition to "the width of my own
content" in a way that is safe across browsers yet, so the controller measures
it: for one synchronous read it lays the bar out at its natural capsule width
with transitions suppressed and `visibility: hidden`, takes the number, and puts
everything back before anything paints. The result is published as
`--ch-capsule-w`, and the transition then runs between two plain lengths. It is
re-measured on viewport resize and after web fonts settle.
**The collapsing word.** `grid-template-columns: 1fr → 0fr` on a wrapper whose
child clips is the one width collapse that animates reliably without a measured
pixel value. It is deliberately *not* `display: none`, `visibility: hidden` or
`aria-hidden` — the word is the brand link's accessible name and stays in the
accessibility tree in both states. Only its painted size changes.
Both directions use the same declarations, so the reverse is the forward
transition run backwards; there is no separate "collapse" path to drift out of
sync. The bar's height is constant, and it is `position: fixed`, so nothing in
the page moves at any point — measured mid-transition at 60ms intervals: width
eased 832 → 497 → 407 → 373 → 361 → 359px while height stayed at 60px and
`visibility` stayed `visible` throughout.
## Accessibility behaviour
- Semantic `<header>`, `<nav>`, `<ul>`/`<li>`, real `<a>` links and a real
`<button>`. No `div` acting as a control.
- The menu button carries `aria-expanded`, `aria-controls` pointing at the
sheet's per-instance id, and a visually-hidden text label as its accessible
name.
- Both `<nav>` landmarks take their accessible name from `menuLabel`.
- `aria-current="page"` on the link matching `activePath`, in the desktop nav
and in the sheet.
- **Closed-menu links are not focusable.** The sheet is `display: none` until
opened, so its links are out of the tab order — verified as 0 focusable links
closed, 4 open.
- **Opening moves focus into the sheet**, to its first link.
- **Focus is trapped** while open. Tab and Shift+Tab cycle the sheet's links
plus the menu button; the button is deliberately part of the cycle, since it
is what closes the sheet.
- **Escape closes and returns focus to the button.** Escape is bound to the
document, so it works wherever focus happens to be.
- Choosing a link closes the menu; so does clicking outside the header.
- Focus returns to the button on close — unless focus has already moved to
wherever a followed link took it.
- All controls are at least 44×44px: the menu button is exactly 44×44, nav
links and the brand have `min-height: 44px`, sheet links 48px.
- Visible focus rings on every control, drawn in `--ch-ring` with a 3px offset.
- The header is `position: fixed` but its wrapper is `pointer-events: none`, so
it never intercepts clicks meant for the page.
- **Skip links keep working.** The component adds nothing before itself in the
DOM, so a skip link the layout puts first stays the first focusable element.
Verified: first Tab reaches the skip link, second the page, third the brand.
- **200% zoom** (720×450, i.e. a 1440 window at 200%): no horizontal overflow,
the layout crosses to the mobile bar, and the menu button is reachable.
### The breakpoint is genuinely configurable
A media query cannot read a runtime custom property, so this could easily have
been a prop that moved the JavaScript and left the layout behind. It is not:
the controller mirrors the breakpoint onto the root as `data-ch-mobile`, and
the layout keys off that attribute rather than a media query. The no-JS
fallback, which does need a real media query, is emitted per instance at the
same value.
Audited with `breakpoint={900}` — the controller, the CSS layout, the desktop
navigation and the mobile menu agree at every width:
| Viewport | `data-ch-mobile` | Desktop nav | Menu button | Capsule on scroll |
| :--- | :--- | :--- | :--- | :--- |
| 850 | yes | hidden | shown, opens 4 links | no |
| 900 | yes | hidden | shown, opens 4 links | no |
| 901 | no | 4 links | hidden | yes — 357px, 500px radius |
| 950 | no | 4 links | hidden | yes — 357px, 500px radius |
## Reduced-motion behaviour
Under `prefers-reduced-motion: reduce`, `--ch-duration` drops to `1ms` and the
sheet's entrance animation with it. The state change still happens in full —
measured 120ms after a scroll: the capsule width had already arrived at its
final 359px, the radius was `500px`, and the transition duration read `0.001s`.
Nothing is hidden to achieve that. The brand word is still in the accessibility
tree with its text intact, and all four navigation links are still exposed.
Reduced motion removes the movement, not the content.
## No-JavaScript behaviour
The controller adds `data-ch-ready` to the root, and every JS-driven state is
gated behind it. With JavaScript disabled:
- the header renders as the expanded bar and stays there — no half-applied
capsule, no `auto` width;
- **above** the breakpoint you get the inline navigation and no sheet;
- **at or below** it the inline navigation is hidden and the link list is shown
in place beneath the bar — and **there is no menu button**, because without
the controller it could not open or close anything. A control that does
nothing is worse than no control.
That fallback list scrolls itself if it is taller than the space between the
bar and the bottom of the screen, so a long navigation stays reachable, and it
never touches the page's own scrolling — the scroll lock is controller-only.
The links are ordinary anchors, so they are keyboard reachable with nothing
else needed.
Exactly one set of links is visible at any width — verified at 390, 700, 768,
769, 800 and 1200 with the default breakpoint, and at 700, 850, 900, 901, 950
and 1200 with `breakpoint={900}`: five visible links (brand plus four) in every
case, never nine.
That fallback is a real media query at the configured breakpoint, emitted per
instance in the markup and scoped to that instance's `data-ch-uid`. A media
query cannot read a custom property, so the alternative would have been a
hardcoded 768 that silently disagreed with the prop.
It is delivered with **`set:text`**. The component uses **no `set:html`
anywhere** — a `<style>` element's text content is its stylesheet, so the safe
text channel is also the correct one. 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 its rule; the
generated string therefore contains no quotes, angle brackets or ampersands.
The uid is reduced to `[A-Za-z0-9_-]` and forced to start with a letter, which
makes it a valid CSS identifier and lets the attribute selector go unquoted, so
an `id` containing quotes or braces cannot break out of it. The breakpoint is
already coerced to a non-negative number.
## Multiple instances
Two or more on a page are independent:
- ids are minted per instance (`ch-xxxxxxx-sheet`), or seeded from `id` if you
pass one — verified as 0 duplicate ids with two instances on a page;
- every query is scoped to the instance root, so opening one menu leaves the
other closed;
- the body scroll lock is counted at module scope, so the second instance's
close cannot release a lock the first one is still holding — verified as
`hidden` while one is open and `visible` once it closes.
## Astro navigation
Written for a project running `<ClientRouter />` as well as one that is not:
- boots on first load **and** on every `astro:page-load`, with the listener
registered once per module so it cannot accumulate;
- init is idempotent — a `data-ch-ready` flag means a second boot over the same
element is a no-op rather than a second set of listeners;
- every listener carries an `AbortController` signal, and `astro:before-swap`
closes the menu, releases the scroll lock, aborts the controller and clears
the ready flag.
Verified against a real ClientRouter transition: a `window` sentinel survived,
Navigation Timing stayed at one unchanged entry, and on arrival there was one
root, no duplicate ids, the menu closed, the body unlocked, the capsule state
reset, the width re-measured for the new route, and the morph working again.
## Cloudflare / static compatibility
Builds to static HTML, CSS and one small JS module. It needs **no** SSR
adapter, API route, Pages Function, Worker, database, cookie, session or
environment variable, and makes no network request of its own — verified as
zero external requests from the preview. It works in `output: "static"` and
deploys to Cloudflare Pages as plain files.
## Copying it into another Astro project
Copy **`CapsuleHeader.astro`** into your components folder — in this catalog it
lives in `src/components/library/` because that is where the catalog looks for
components, but in a normal site `src/components/global/` is the more natural
home. Then render it in your shared layout:
```astro
---
// src/layouts/BaseLayout.astro
import CapsuleHeader from "../components/global/CapsuleHeader.astro";
const navigation = [
{ label: "Services", href: "/services/" },
{ label: "Industries", href: "/industries/" },
{ label: "About", href: "/about/" },
{ label: "Contact", href: "/contact/" },
];
---
<html lang="en">
<body>
<a class="skip" href="#main">Skip to main content</a>
<CapsuleHeader
brandName="Northstar"
brandHref="/"
links={navigation}
activePath={Astro.url.pathname}
capsuleBackground="rgb(255 255 255 / 72%)"
blur="12px"
>
<svg slot="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="currentColor" />
<path d="M9 16h14M16 9v14" stroke="white" stroke-width="2" />
</svg>
</CapsuleHeader>
<main id="main" tabindex="-1"><slot /></main>
</body>
</html>
```
Then two things the host page owes it:
**Reserve its height.** It is `position: fixed`, so it is out of the flow.
Either open on a full-bleed hero that is meant to sit under it, or pad the
content:
```css
main { padding-top: 5.5rem; } /* --ch-top + --ch-height + air */
:target, h1, h2 { scroll-margin-top: 6rem; } /* so hash targets clear it */
```
**Give it something to be legible against.** `expandedColor` defaults to white
because the expanded bar is transparent and the design assumes a dark hero. Over
a light first screen, set `expandedColor` to something dark — or give
`expandedBackground` a fill.
## Measurable differences from the reference
1. **CSS transitions instead of scripted frames.** The reference writes inline
styles per frame from a JavaScript animation library. This uses CSS
transitions plus one measurement, so it has no dependency and honours
`prefers-reduced-motion` through the cascade.
2. **Escape closes the mobile menu.** In the reference it does not — pressing
Escape with the menu open left `aria-expanded="true"` and the overlay up.
3. **No horizontal overflow with the menu open.** The reference's open overlay
made `documentElement.scrollWidth` exceed `clientWidth` at 390px; this one
does not, at 390 or 320.
4. **The brand word stays in the accessibility tree** while collapsed, rather
than being removed.
5. **No entrance animation.** The reference drops the header in from above on
first load. That is a page-load flourish rather than the capsule behaviour,
and it is not reproduced.
6. **Nav spacing and geometry** are the measured reference values (34px → 17px
gap, 19px → 28px padding, 500px radius, 10px threshold, 60px height), but
the capsule width is whatever your own content measures — 359px for the
four-link demo, against 353px for theirs.
7. **Different content, different mark.** Nothing of theirs is reproduced: the
fallback mark is the brand initial in a ruled square, and the demo content is
fictional.
## Known limitations
- **The capsule is only as small as its content.** A long brand name or six or
more links produce a capsule close to full width, and the contraction stops
reading as one. That is a content decision, not a defect.
- **A runtime content change with no resize does not re-measure.** Swapping the
brand text from script without a viewport change will leave the previous
capsule width until the next resize. Dispatching `window.dispatchEvent(new
Event("resize"))` triggers a fresh measurement; a `ResizeObserver` on the bar
was tried and removed, because the bar resizes *because of* the morph and
observing it made the morph re-enter its own measuring pass every frame.
- **`backdrop-filter` is not universal.** Where it is unsupported the capsule
still gets its fill, radius, colour change and spacing; only the blur is
missing. Raise `capsuleBackground`'s alpha if that matters to you.
- **Outside-click has little room on small screens.** The sheet fills the
viewport inside a 10px gutter, so the only region outside it is that gutter
and the strip above the bar. It works there, but Escape, the menu button and
choosing a link are the dismissal paths people will actually use.
===== END src/components/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.