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 "Expanding pill nav" 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: Expanding pill nav
Library page: https://astro.baysixmedia.com/components/expanding-pill-nav/
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: no
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
Prop interface, verbatim from the component:
interface Props {
/** Accessible name of the brand link. Also its visually hidden text.
* Omitted, the link is named "Home". */
brandLabel?: string;
/** Where the brand mark links to. */
brandHref?: string;
/** The navigation links. */
links?: NavLink[];
/** Optional call to action at the end of the list. */
ctaLabel?: string;
/** Destination for the call to action. Without one it is not a link. */
ctaHref?: string;
/** Accessible name for the `<nav>`, for pages with more than one. */
navLabel?: string;
/** Width the rail settles at before you scroll, once there is room for it. */
restWidth?: string;
/** Width it grows to after scrolling past the threshold. */
scrolledWidth?: string;
/** Pixels of scroll before the pill widens. */
scrollThreshold?: number;
/** How long the width change takes. */
growMs?: number;
/** Distance from the top of the viewport on small screens. */
topOffset?: string;
/** Distance from the top once the wide layout applies. */
topOffsetWide?: string;
/** Horizontal inset from the viewport edges. */
inset?: string;
/** Pill fill. Translucent by design — the blur behind it is the point. */
surface?: string;
/** Hairline around the pill. */
border?: string;
/** Backdrop blur behind the pill. `none` turns the glass off. */
blur?: string;
/** Link and brand colour. */
ink?: string;
/** Link hover colour, focus ring, and the call to action's fill. */
accent?: string;
/** Text colour on the call to action. */
accentInk?: string;
/** Corner radius below the wide breakpoint. */
radius?: string;
/** Corner radius once the wide layout applies. */
radiusWide?: string;
/** Stacking order. It floats over the page, so it has to win. */
zIndex?: number;
/** Added to the root element. */
class?: string;
/** Root element id. Also seeds the menu id. */
id?: string;
}
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 topOffsetWide plus the rail height, or the first heading starts underneath it.
- Floats inset from the viewport edges rather than spanning it, so a full-bleed hero shows through on both sides by design.
- Translucent with a backdrop blur. Over a light page the default dark fill needs changing, not just the ink.
- Set `current` on the link matching the active route; the component does not infer it from the URL.
--- 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; 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/ExpandingPillNav.astro =====
---
/**
* Expanding pill nav
*
* A floating glass navigation pill that sits over the page and widens once you
* scroll away from the top. Below the wide breakpoint it spans the full width;
* below the menu breakpoint the links collapse behind a toggle that opens them
* inside the pill.
*
* Self-contained: no imports, no packages, no global stylesheet, no images.
* Every colour and measurement is a `--pn-*` custom property on the root.
*/
export interface NavLink {
label: string;
href: string;
/** Marks this one as the page you are on. */
current?: boolean;
}
interface Props {
/** Accessible name of the brand link. Also its visually hidden text.
* Omitted, the link is named "Home". */
brandLabel?: string;
/** Where the brand mark links to. */
brandHref?: string;
/** The navigation links. */
links?: NavLink[];
/** Optional call to action at the end of the list. */
ctaLabel?: string;
/** Destination for the call to action. Without one it is not a link. */
ctaHref?: string;
/** Accessible name for the `<nav>`, for pages with more than one. */
navLabel?: string;
/** Width the rail settles at before you scroll, once there is room for it. */
restWidth?: string;
/** Width it grows to after scrolling past the threshold. */
scrolledWidth?: string;
/** Pixels of scroll before the pill widens. */
scrollThreshold?: number;
/** How long the width change takes. */
growMs?: number;
/** Distance from the top of the viewport on small screens. */
topOffset?: string;
/** Distance from the top once the wide layout applies. */
topOffsetWide?: string;
/** Horizontal inset from the viewport edges. */
inset?: string;
/** Pill fill. Translucent by design — the blur behind it is the point. */
surface?: string;
/** Hairline around the pill. */
border?: string;
/** Backdrop blur behind the pill. `none` turns the glass off. */
blur?: string;
/** Link and brand colour. */
ink?: string;
/** Link hover colour, focus ring, and the call to action's fill. */
accent?: string;
/** Text colour on the call to action. */
accentInk?: string;
/** Corner radius below the wide breakpoint. */
radius?: string;
/** Corner radius once the wide layout applies. */
radiusWide?: string;
/** Stacking order. It floats over the page, so it has to win. */
zIndex?: number;
/** Added to the root element. */
class?: string;
/** Root element id. Also seeds the menu id. */
id?: string;
}
const {
// No invented brand, routes or call to action: the site supplies its own.
brandLabel = "",
brandHref = "/",
links = [],
ctaLabel = "",
ctaHref,
navLabel = "Primary",
restWidth = "576px",
scrolledWidth = "672px",
scrollThreshold = 64,
growMs = 1000,
topOffset = "8px",
topOffsetWide = "32px",
inset = "32px",
surface = "rgb(26 26 31 / 60%)",
border = "#3d3947",
blur = "24px",
ink = "#ffffff",
accent = "#9f9bf5",
accentInk = "#14121f",
radius = "12px",
radiusWide = "16px",
zIndex = 50,
class: className,
id,
} = Astro.props;
// One id per instance, so several navs can share a page without their toggles
// pointing at each other's menus.
const uid = id ?? `pn-${Math.random().toString(36).slice(2, 9)}`;
const menuId = `${uid}-menu`;
// The menu (and the toggle that opens it) only exists when there is something
// to put in it; with no brand either, there is no header to draw at all.
const hasMenu = links.length > 0 || Boolean(ctaLabel);
const hasContent = hasMenu || Boolean(brandLabel);
const style = [
`--pn-rest:${restWidth}`,
`--pn-scrolled:${scrolledWidth}`,
`--pn-grow:${growMs}ms`,
`--pn-top:${topOffset}`,
`--pn-top-wide:${topOffsetWide}`,
`--pn-inset:${inset}`,
`--pn-surface:${surface}`,
`--pn-border:${border}`,
`--pn-blur:${blur}`,
`--pn-ink:${ink}`,
`--pn-accent:${accent}`,
`--pn-accent-ink:${accentInk}`,
`--pn-radius:${radius}`,
`--pn-radius-wide:${radiusWide}`,
`--pn-z:${zIndex}`,
].join(";");
---
{hasContent && (
<header
class:list={["nav", className]}
id={id}
style={style}
data-header-root
data-pn-root
data-pn-threshold={scrollThreshold}
>
{/* The rail is what animates its width. Keeping it separate from the pill
means the pill never has to know how wide it is — it just fills. */}
<div class="rail" data-pn-rail>
<div class="pill">
<div class="head">
<a class="brand" href={brandHref}>
<span class="sr-only">{brandLabel || "Home"}</span>
{/* Original mark: two offset rings and a dot. No wordmark, so
nothing to replace when this lands in another project. */}
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<circle cx="16" cy="16" r="12"></circle>
<circle cx="21" cy="13" r="7"></circle>
<circle class="mark-dot" cx="12" cy="20" r="2.75"></circle>
</svg>
</a>
{hasMenu && (
<button
type="button"
class="toggle"
data-pn-toggle
aria-expanded="false"
aria-controls={menuId}
>
<span class="sr-only" data-pn-toggle-label>Open menu</span>
<svg class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<line class="bar bar-a" x1="4" y1="8" x2="20" y2="8"></line>
<line class="bar bar-b" x1="4" y1="16" x2="20" y2="16"></line>
</svg>
</button>
)}
</div>
{hasMenu && (
<nav class="menu" id={menuId} data-pn-menu aria-label={navLabel}>
<ul class="list">
{
links.map((link) => (
<li>
<a
class="link"
href={link.href}
aria-current={link.current ? "page" : null}
>
{link.label}
</a>
</li>
))
}
{
ctaLabel && (
<li class="cta-item">
{ctaHref ? (
<a class="cta" href={ctaHref}>
{ctaLabel}
</a>
) : (
/* No destination, so no link: a dead anchor that looks
clickable is worse than plain text. */
<span class="cta is-static">{ctaLabel}</span>
)}
</li>
)
}
</ul>
</nav>
)}
</div>
</div>
</header>
)}
<style>
.nav {
position: fixed;
inset-inline: 0;
top: var(--pn-top);
z-index: var(--pn-z);
box-sizing: border-box;
padding-inline: var(--pn-inset);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-size: 16px;
line-height: 1.5;
}
/* Set rather than inherited: a host stylesheet must not be able to change
the pill's height or push a link onto a second row. */
.nav *,
.nav *::before,
.nav *::after {
box-sizing: border-box;
}
.nav ul {
margin: 0;
padding: 0;
list-style: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- the rail: full width until there is room to settle -------------- */
.rail {
max-width: none;
margin-inline: auto;
transition: max-width var(--pn-grow) cubic-bezier(0.4, 0, 0.2, 1);
}
/* --- the pill --------------------------------------------------------- */
.pill {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
padding: 8px;
border: 1px solid var(--pn-border);
border-radius: var(--pn-radius);
background: var(--pn-surface);
-webkit-backdrop-filter: blur(var(--pn-blur));
backdrop-filter: blur(var(--pn-blur));
/* The reference stacks five shadows at the same colour and doubling
radii, which is what keeps the edge crisp while the spread is soft. */
box-shadow:
0 4px 4px rgb(0 0 0 / 15%),
0 8px 8px rgb(0 0 0 / 15%),
0 16px 16px rgb(0 0 0 / 15%),
0 32px 32px rgb(0 0 0 / 15%),
0 64px 64px rgb(0 0 0 / 15%);
}
.head {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.brand {
display: block;
flex: none;
width: 32px;
height: 32px;
border-radius: 8px;
color: var(--pn-ink);
}
.mark {
display: block;
width: 32px;
height: 32px;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
}
.mark-dot {
fill: var(--pn-accent);
stroke: none;
}
/* --- toggle ----------------------------------------------------------- */
.toggle {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--pn-border);
border-radius: 8px;
background: rgb(255 255 255 / 4%);
color: var(--pn-ink);
font: inherit;
cursor: pointer;
}
/*
* The drawn control is 36px, which is the design and stays the design. The
* tappable area is not: 36 is under the 44px minimum, and this is the only
* control on the layout where the whole menu lives behind it.
*
* An invisible extension rather than a bigger button, so the pill's
* proportions are untouched and nothing moves. Inset -4px on each side
* takes 36 to 44 exactly.
*/
.toggle::after {
content: "";
position: absolute;
inset: -4px;
}
.toggle:hover {
background: rgb(255 255 255 / 10%);
}
.bars {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
}
.bar {
transition:
transform 200ms ease,
opacity 200ms ease;
transform-origin: center;
}
/* Two bars crossing into an X, so the control shows its own state rather
than relying on the menu below it to say so. */
[data-pn-open] .bar-a {
transform: translateY(4px) rotate(45deg);
}
[data-pn-open] .bar-b {
transform: translateY(-4px) rotate(-45deg);
}
/* --- menu ------------------------------------------------------------- */
.menu {
display: flex;
flex-direction: column;
flex-grow: 1;
justify-content: center;
padding-block: 24px;
}
.list {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
text-align: center;
}
.link {
display: inline-block;
padding: 2px 4px;
border-radius: 4px;
color: var(--pn-ink);
font-size: 0.875rem;
text-decoration: none;
transition: color 160ms ease;
}
.link:hover {
color: var(--pn-accent);
}
.link[aria-current="page"] {
color: var(--pn-accent);
}
.cta-item {
display: flex;
justify-content: center;
}
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 8px 24px;
border-radius: 8px;
background: var(--pn-accent);
color: var(--pn-accent-ink);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition: filter 160ms ease;
}
.cta:hover {
filter: brightness(1.08);
}
.cta.is-static {
background: rgb(255 255 255 / 10%);
color: var(--pn-ink);
cursor: default;
}
/* One ring for every control. The reference leaves the browser default,
which on a dark glass surface is close to invisible. */
.brand:focus-visible,
.toggle:focus-visible,
.link:focus-visible,
.cta:focus-visible {
outline: 2px solid var(--pn-accent);
outline-offset: 3px;
}
/* --- collapsed, once the script is running ---------------------------- */
/*
* The menu is open markup by default, so with no JavaScript every link is
* reachable. The script adds `data-pn-js`, and only then does the menu
* collapse behind the toggle below the menu breakpoint.
*/
.toggle {
display: none;
}
@media (max-width: 767.98px) {
[data-pn-js] .toggle {
display: flex;
}
[data-pn-js] .menu {
display: none;
}
[data-pn-js][data-pn-open] .menu {
display: flex;
}
}
/* --- menu breakpoint: links inline ------------------------------------ */
@media (min-width: 768px) {
.pill {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.menu {
padding-block: 0;
}
.list {
flex-direction: row;
gap: 12px;
}
.link {
padding: 2px 6px;
}
}
/* --- wide breakpoint: the rail settles and starts growing -------------- */
@media (min-width: 1024px) {
.nav {
top: var(--pn-top-wide);
}
.pill {
border-radius: var(--pn-radius-wide);
}
.rail {
max-width: var(--pn-rest);
}
[data-pn-scrolled] .rail {
max-width: var(--pn-scrolled);
}
}
/* --- reduced motion ---------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
.rail,
.bar,
.link,
.cta {
transition-duration: 0.01ms;
}
}
</style>
<script>
/**
* One controller per instance, and one boot per page view.
*
* Three things this has to survive that a section never does. It is
* installed once into a shared layout, so a destination project with
* `<ClientRouter />` swaps the document underneath it and the module never
* re-evaluates: initialisation therefore hangs off `astro:page-load` as
* well as first load. It can be booted more than once, so init is
* idempotent. And its listeners outlive the element unless something
* removes them, so every one of them is bound to an AbortController that
* is aborted before the swap.
*/
function initExpandingPillNav(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.pnReady === "") return;
const toggle = root.querySelector<HTMLButtonElement>("[data-pn-toggle]");
const menu = root.querySelector<HTMLElement>("[data-pn-menu]");
const label = root.querySelector<HTMLElement>("[data-pn-toggle-label]");
if (!toggle || !menu) return;
root.dataset.pnReady = "";
// Turning this on is what enables the collapsed layout at all, so the
// fully expanded menu is what renders if this script never runs.
root.setAttribute("data-pn-js", "");
/* Every listener below carries this signal, so one abort() detaches the
lot — including the ones on window and document, which would
otherwise pile up one set per client-side navigation. */
const controller = new AbortController();
const { signal } = controller;
const threshold = Number(root.dataset.pnThreshold) || 64;
let open = false;
let frame = 0;
function setOpen(next: boolean) {
open = next;
toggle!.setAttribute("aria-expanded", String(next));
if (label) label.textContent = next ? "Close menu" : "Open menu";
if (next) root.setAttribute("data-pn-open", "");
else root.removeAttribute("data-pn-open");
}
toggle.addEventListener("click", () => setOpen(!open), { signal });
/*
* Escape closes it and puts focus back on the control that opened it,
* rather than leaving focus inside a menu that is no longer on screen.
*
* Bound to the document rather than to the root. Scoped to the root it
* only fired while focus was already inside the header, so Escape did
* nothing whenever the menu had been opened by a tap — or, in a project
* with a client-side router, on the page after a navigation, where
* focus starts on the body. The menu covers content while it is open,
* so Escape has to work wherever focus happens to be.
*
* Still safe with several navs on a page: the handler returns unless
* *this* instance is the one that is open.
*/
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
setOpen(false);
toggle.focus();
},
{ signal },
);
// A link inside the collapsed menu has been followed, so close it.
menu.addEventListener(
"click",
(event) => {
if (open && (event.target as HTMLElement).closest("a")) setOpen(false);
},
{ signal },
);
/* Tapping the page behind an open menu should close it. Without this the
menu stays over the content the tap was aimed at, which on a phone is
the whole screen. */
document.addEventListener(
"pointerdown",
(event) => {
if (!open || root.contains(event.target as Node)) return;
setOpen(false);
},
{ signal },
);
/*
* Past the threshold the rail is allowed to grow. The class does the
* moving: the only job here is deciding which side of the line we are
* on, and doing it once per frame rather than once per scroll event.
*/
function measure() {
const past = window.scrollY > threshold;
if (past === root.hasAttribute("data-pn-scrolled")) return;
if (past) root.setAttribute("data-pn-scrolled", "");
else root.removeAttribute("data-pn-scrolled");
}
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}
window.addEventListener("scroll", schedule, { passive: true, signal });
measure();
/* Astro replaces the document on a client-side navigation. Detach first,
and drop the ready flag so a re-used element can be booted again. */
document.addEventListener(
"astro:before-swap",
() => {
if (frame) cancelAnimationFrame(frame);
setOpen(false);
controller.abort();
delete root.dataset.pnReady;
},
{ once: true, signal },
);
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-pn-root]")
.forEach(initExpandingPillNav);
/* 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/ExpandingPillNav.astro =====
===== FILE (supporting): src/components/ExpandingPillNav.md =====
# Expanding pill nav
A floating glass navigation pill that sits over the page and **widens once you
scroll away from the top**. Below the wide breakpoint it spans the full width;
below the menu breakpoint the links collapse behind a toggle that opens them
inside the pill.
Catalog page: `/components/expanding-pill-nav`
## What the reference selector matched, and where the boundary went
The selector `#nav-container > div` returned **exactly one element**: the pill
itself — a flex row holding a 32×32 brand mark, a hidden-on-desktop toggle
button, and a `<nav>` with five links and a call to action.
**The pill alone is a static bar.** Both of the things that make it what it is
live on ancestors *outside* the selector:
| Element | What it contributed |
| :--- | :--- |
| `#nav-wrapper` (grandparent) | `position: fixed; inset-x: 0; z-index: 50; top: 8px → 32px` — makes it float |
| `#nav-container` (parent) | `transition-all duration-1000` with `max-width` swapping 576px → 672px on scroll — **the signature effect** |
Measured proof that the effect is not in the matched element: the pill's own
computed `transition` is `all` with **no duration**, and its `max-width` is
`none`. It does not animate. The parent's `max-width` changes from `576px` to
`672px` — a class swap — and the pill simply fills it, growing 512 → 608px.
So the boundary was expanded to take in both ancestors' behaviour, folded into
this component's own root and rail rather than left as wrappers a user has to
remember to supply. Without them the component would reproduce the appearance
and none of the interaction.
Nothing else from the page — hero, sections, footer — was inspected or included.
## Copying it into another Astro project
Copy **`ExpandingPillNav.astro`**. That is the whole component.
| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | none |
| Astro built-ins (`astro:assets`, …) | none |
| Global stylesheet | none — every value is a `--pn-*` property on the root |
| Webfonts | none — system stack |
| Images or icons | **none** — the mark and the toggle are inline SVG shapes |
| JavaScript | ~50 lines, inline in the file |
| Network requests | none |
| Host requirement | Astro compiles `.astro` files |
### Exact steps
1. Copy `ExpandingPillNav.astro` into your components folder.
2. Render it once, near the top of your layout:
```astro
---
import ExpandingPillNav from "../components/ExpandingPillNav.astro";
---
<ExpandingPillNav
brandLabel="Your studio home"
brandHref="/"
links={[
{ label: "Work", href: "/work", current: true },
{ label: "Services", href: "/services" },
{ label: "Contact", href: "/contact" },
]}
ctaLabel="Start a project"
ctaHref="/start"
/>
```
3. **Give your page top padding.** The nav is `position: fixed`, so it adds no
height and your first section will slide underneath it. Roughly
`topOffsetWide + 54px + breathing room` — about 120px at desktop.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brandLabel` | `string` | `""` | Accessible name of the brand link, and its visually hidden text. Empty, the link is named "Home". |
| `brandHref` | `string` | `"/"` | Where the brand mark links to. |
| `links` | `NavLink[]` | `[]` | The navigation links. |
| `ctaLabel` | `string` | `""` | Call to action at the end. `""` hides it. |
| `ctaHref` | `string` | — | Destination. **Without one the label renders as plain text, not a link.** |
**No invented site.** The component ships no brand name, routes or call to
action of its own — the catalog preview passes its demo copy as props. With no
links and no call to action, the menu and its toggle are not rendered; with no
brand label either, the component renders nothing at all.
| `navLabel` | `string` | `"Primary"` | Accessible name for the `<nav>`. |
| `restWidth` | `string` | `"576px"` | Rail width before scrolling, once the wide layout applies. |
| `scrolledWidth` | `string` | `"672px"` | Width it grows to after the threshold. |
| `scrollThreshold` | `number` | `64` | Pixels of scroll before it widens. |
| `growMs` | `number` | `1000` | How long the width change takes. |
| `topOffset` | `string` | `"8px"` | Distance from the top on small screens. |
| `topOffsetWide` | `string` | `"32px"` | Distance from the top at the wide breakpoint. |
| `inset` | `string` | `"32px"` | Horizontal inset from the viewport edges. |
| `surface` | `string` | `"rgb(26 26 31 / 60%)"` | Pill fill. Translucent by design. |
| `border` | `string` | `"#3d3947"` | Hairline around the pill. |
| `blur` | `string` | `"24px"` | Backdrop blur. `0px` turns the glass off. |
| `ink` | `string` | `"#ffffff"` | Link and brand colour. |
| `accent` | `string` | `"#9f9bf5"` | Hover colour, focus ring, current link, CTA fill. |
| `accentInk` | `string` | `"#14121f"` | Text on the CTA. |
| `radius` | `string` | `"12px"` | Corner radius below the wide breakpoint. |
| `radiusWide` | `string` | `"16px"` | Corner radius at the wide breakpoint. |
| `zIndex` | `number` | `50` | Stacking order. It floats, so it has to win. |
| `class` | `string` | — | Added to the root. |
| `id` | `string` | — | Root element id. Also seeds the menu id. |
### `NavLink`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | Link text. |
| `href` | `string` | Destination. |
| `current` | `boolean` | Marks it `aria-current="page"` and tints it with the accent. |
## Responsive behavior
| Viewport | Layout |
| :--- | :--- |
| ≥ 1024px | Rail settles to `restWidth`, grows to `scrolledWidth` on scroll. Top offset 32px, radius 16px. |
| 768–1023px | Rail is full width minus `inset`. Links still inline. Top offset 8px, radius 12px. |
| < 768px | Pill becomes a column. Links collapse behind the toggle. |
Measured in the catalog preview:
| Width | Rail | Pill | Direction | Toggle |
| :--- | --: | :--- | :--- | :--- |
| 1280 | `max-width: 576px` | 576 × 54 | row | hidden |
| 834 | `none` | 770 × 54 | row | hidden |
| 390 | `none` | 326 × 53 closed, × 387 open | column | shown |
| 320 | `none` | 256 × 53 closed, × 387 open | column | shown |
`scrollWidth === clientWidth` at all four.
## The growth, measured
| Scroll | State |
| :--- | :--- |
| 0 | 576px |
| 64 | 576px |
| 65 | 672px |
| 200 | 672px |
| back to 0 | 576px |
Transition is `max-width 1s cubic-bezier(0.4, 0, 0.2, 1)` — the reference's
exact duration and curve. The threshold is the one value that could not be read
directly: the reference swaps its class somewhere in `50 < t ≤ 80`, so this uses
**64** and exposes it as `scrollThreshold`.
Only `max-width` animates. The pill's height, padding and content never move, so
the growth cannot cause layout shift — and because the whole thing is `fixed`, it
cannot shift the page either.
## Accessibility
- **The toggle reports its state.** `aria-expanded` flips between `false` and
`true`, and `aria-controls` points at the menu's per-instance id. *The
reference has an `aria-label` but neither `aria-expanded` nor `aria-controls`,
so assistive tech is never told the menu opened.*
- **Escape closes the menu and returns focus to the toggle**, rather than
leaving focus inside a menu that is no longer on screen.
- **A collapsed menu is `display: none`**, so its links are out of the tab order
rather than invisible but focusable.
- **Visible focus** on every control: a 2px accent outline at a 3px offset.
*The reference leaves the browser default — computed `auto 0.67px`, close to
invisible on dark glass.*
- **`aria-current="page"`** marks the active link, which the accent colour then
reinforces — state is not carried by colour alone.
- **The brand link has real text** in a visually hidden span, so it is never an
unlabelled link.
- **The toggle's own label changes** between "Open menu" and "Close menu", and
the two bars cross into an X, so the control says what it will do.
- **A missing `ctaHref` never produces a dead link** — the label renders as a
`<span>`, not an `<a href="#">`.
### Reduced motion
Under `prefers-reduced-motion: reduce` every transition drops to `0.01ms` —
verified on the rail, the links and the toggle bars. The nav still widens, still
opens and still closes; it just does so instantly.
### Without JavaScript
The menu is open markup by default. The script adds `data-pn-js`, and only then
does the collapsed layout apply below 768px. Verified with scripting disabled at
390px: the menu computes `display: flex`, the toggle is `display: none`, and all
**7** links are reachable. Nothing is hidden behind a control that cannot work.
The scroll growth simply does not happen without JavaScript — the rail stays at
`restWidth`, which is a valid resting state rather than a broken one.
## Multiple instances
**Verified with two on one page**, one using the defaults and one with a teal
accent, square corners, different widths and a 120px threshold:
- Ids are per-instance (`nav-a-menu`, `nav-b-menu`); every `aria-controls`
resolves inside its own nav and no id is duplicated.
- Opening the second left the first closed.
- At `scrollY: 90` the first had widened (threshold 64) and the second had not
(threshold 120) — each reads the scroll position against its own setting.
## Customisation
Everything visual is a custom property on the root:
```css
.my-nav {
--pn-surface: rgb(255 255 255 / 70%);
--pn-border: #e4e4e9;
--pn-ink: #14121f;
--pn-accent: #4f46e5;
--pn-accent-ink: #ffffff;
}
```
Pass `class="my-nav"` to attach it, or use the equivalent props inline. For a
light page, raise `surface` towards white and drop `ink` to near-black; the blur
and the hairline do the rest.
**The glass needs something behind it.** `backdrop-filter` has nothing to blur
over a flat ground — it will look like a plain translucent panel, which is what
the reference looks like at the top of its own page. The effect appears as
content scrolls underneath.
## Differences from the reference
| | Reference | Here |
| :--- | :--- | :--- |
| Toggle state | `aria-label` only | `aria-expanded` + `aria-controls`, and a changing label |
| Escape | not handled | closes and restores focus |
| Focus ring | browser default, ~0.67px | 2px accent at 3px offset |
| Active link | not marked | `aria-current="page"` |
| Structure | effect split across three elements | one component, one root |
| Missing CTA destination | n/a | renders as text, never a dead anchor |
| Brand mark | a multi-path radial logo | an original two-ring mark with an accent dot |
The threshold is the one measured value carried over as an approximation: the
reference's class swap falls in `50 < t ≤ 80` and this uses 64.
## Originality
Every line of markup, CSS and JavaScript here was written for this catalog. The
reference was measured in a browser — computed styles, bounding boxes and class
changes at sampled scroll offsets — not copied. No source, class names, utility
framework, wording, brand or product names, logo, fonts, scripts, analytics,
external URLs or assets came from it. The demo copy (passed by the catalog as
props, not built in) is original and fictional, the brand mark is an original SVG, and the component ships no images
and makes no network requests.
## Installing this as a production header
**Installation mode:** single file, static navigation, **fixed** positioning, with a small-screen menu.
This component is registered as **Navigation**, so its catalog page carries a
header-readiness panel, a header-installation prompt and a **Copy header kit**
action, and it has a full-page integration fixture at
`/integration-test/navigation/expanding-pill-nav/`. The difference between a visual
component and a production header, and what that machinery does, is in the
project README.
### Required files
| File | Destination | Required |
| :--- | :--- | :--- |
| `ExpandingPillNav.astro` | `src/components/ExpandingPillNav.astro` | yes |
| `ExpandingPillNav.md` | — | documentation |
One required file. No package, no stylesheet, no asset.
### Mapping routes into it
`links` is a flat list — there are no panels to fill. Set `current` on the one
matching the active route; the component does not infer it from the URL.
```astro
---
import Nav from "../components/ExpandingPillNav.astro";
const path = Astro.url.pathname;
---
<Nav
brandLabel="Your studio"
brandHref="/"
links={[
{ label: "Work", href: "/work", current: path.startsWith("/work") },
{ label: "About", href: "/about", current: path.startsWith("/about") },
{ label: "Contact", href: "/contact", current: path.startsWith("/contact") },
]}
ctaLabel="Start a project"
ctaHref="/start"
/>
```
### Replacing the existing header
1. Find the shared layout and the current header. Do not assume
`src/layouts/Layout.astro`.
2. Inventory every route in the existing header before deleting anything.
3. Render it as a direct child of `<body>`, after the skip link and before
`<main>`.
4. Add the top reserve described below, at every breakpoint.
5. Build it alongside the old header first; delete the old markup, its menu
JavaScript and its CSS only once this one works on every route.
6. Check no second header survives in a nested layout or an individual page.
### Positioning requirements
`position: fixed`, so it is **out of the flow** and the page owes it height.
```css
main { padding-top: 7.5rem; } /* topOffsetWide + rail height + air */
:target, h1, h2 { scroll-margin-top: 9rem; }
```
Re-check the reserve at every breakpoint — `topOffset` is 8px small and
`topOffsetWide` 32px wide, so the number is not the same at both.
It floats inset from the viewport edges rather than spanning it, so a
full-bleed hero shows through on both sides by design. It is translucent with a
backdrop blur: over a light page change `surface` and `border`, not just `ink`.
The toggle is drawn at 36px and tappable at 44px through an invisible inset
extension on `::after`. If you restyle the toggle, keep that.
### Astro lifecycle behaviour
Installed once into a shared layout, so it has to survive a destination project
running `<ClientRouter />`, where the document is swapped without the bundled
module being re-evaluated.
- Boots on first load **and** on every `astro:page-load`. The `page-load`
listener is registered once per module, so it never accumulates.
- Init is idempotent: a `data-pn-ready` flag on the root means a second boot over
the same element is a no-op rather than a second set of listeners.
- Every listener — including the ones on `document` and `window` — carries an
`AbortController` signal. On `astro:before-swap` the instance closes its
state, aborts the controller and clears its ready flag.
- The root carries `data-header-root` as well as its own `data-pn-root`, so tooling
can find a header without knowing which one it is.
In a project **without** a client router none of this fires and the component
behaves exactly as it always did.
### Client-navigation certification
**Result: `end-to-end-passed`, 2026-09-15.**
Not an event-level test. Certified by clicking real links under a real
`<ClientRouter />` on `/integration-test/navigation-client/expanding-pill-nav/`, waiting for
the router's own `astro:page-load`, over five transitions at 390px and 1440px
plus Back and Forward. Each transition was proved client-side by a `window`
sentinel that survived, a single unchanged Navigation Timing entry, and the
document never reloading.
Verified on every arrival:
- exactly one `[data-header-root]`, no duplicate ids, no broken `aria-controls`
- open state cleared and `aria-expanded` back to `false`
- body scroll released (this one never locks it)
- the outgoing element detached, and clicking its trigger doing nothing
- the new instance initialised, with one activation causing exactly one change
- Escape, outside click and focus all working on the new page
- live document listeners holding at one per type while the aborted count grew
by one per transition — one live instance throughout, nothing accumulating
The header is **replaced** on every transition, not persisted. No
`transition:persist` is used: replacement is the harder case and the one a
destination project gets by default, and adding the directive to make a test
pass would hide the failure mode that matters.
One real defect was found here that the event-level test could not see. Escape
was bound to the component's own root, so it only fired while focus was already
inside the header — and a router leaves focus on the body. Escape now listens on
the document, guarded on this instance being the open one, so it works wherever
focus happens to be. Several navs on a page stay independent.
The touch target was re-reviewed at 390px and 320px: drawn at 36px, tappable at
44px through the inset `::after`, with no overlap against any neighbouring
control, no stolen pointer events, and the extended box inside the rail. The
button box was therefore left at 36px.
### Verification checklist
Work all of it on `/integration-test/navigation/expanding-pill-nav/`, **and** in the
isolated preview at `/preview/expanding-pill-nav/`. A header that passes in one environment
and not the other is not ready.
- [ ] Widths 1440, 1280, 1024, 834, 390, 320 — `scrollWidth === clientWidth`
- [ ] 200% zoom (emulate it by halving the CSS viewport, not with `body { zoom }`)
- [ ] scrollY 0, 1, 100, 500, and back to 0
- [ ] Keyboard only, start to finish: no trap, no focus behind a closed layer
- [ ] Every dropdown: click, Enter, Space, Escape, focus return, outside click
- [ ] Mobile menu: open, close, Escape, focus return, link-click close
- [ ] Resize across the breakpoint while open
- [ ] Skip link is the first focusable element and its target is not covered
- [ ] Hash navigation leaves the target heading visible
- [ ] Astro client navigation, **end to end**: real link clicks under a real
`<ClientRouter />` on the navigation-client fixture, five transitions plus
Back and Forward. Dispatching the events is not enough to certify.
- [ ] JavaScript disabled — every link still reachable
- [ ] `prefers-reduced-motion: reduce`
- [ ] Two instances, independent, no shared state and no shared lock
- [ ] Body scroll restored after every close
- [ ] No duplicate ids
- [ ] No horizontal overflow, no clipped panel, no header over focused content
- [ ] No console errors, no requests originating in the component
===== END src/components/ExpandingPillNav.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.