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 "Overlay pill 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: Overlay pill header
Library page: https://astro.baysixmedia.com/components/overlay-pill-header/
Navigation mode: dropdown — single-column panels that open from their triggers
Position mode: overlay
Desktop dropdowns: yes
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
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:
- Absolutely positioned at top: 0 of its containing block, so it covers whatever the page opens with. Put it as the first child of <body> (or of the hero's positioned wrapper) and let the hero run underneath it. On a page that does NOT open with a hero, either reserve its height (about 98px at desktop, 73px on a phone) or pass overlay={false}, which puts it in the flow on a navy band.
- If an ancestor between it and <body> is positioned (relative, absolute, transform, filter, contain), that ancestor becomes the containing block and the header pins to its top instead of the page's.
- White text on a transparent bar: the hero under it must be dark enough to carry white 14px type. It paints no background of its own in overlay mode.
- The glass pill uses backdrop-filter. It degrades to a flat 10% white fill where that is unsupported, which is still legible on a dark hero.
- The dropdown opens on click, Enter or Space — not on hover — once the script has booted. Without JavaScript it opens on hover and on focus-within, so every link stays reachable.
- The mobile menu card is absolutely positioned under the bar and covers the hero rather than pushing the page down. No body scroll lock is taken.
- z-index is 30. A hero with its own stacking context above that will cover the header; lower the hero or raise this.
- Pass currentPath={Astro.url.pathname} to get aria-current on the active link; it is not inferred. A dropdown trigger is highlighted when one of its children is current.
- An item with `children` renders as a <button>; its own `href` is ignored. Put the parent page in the children list if it needs to be reachable.
- Loads Bebas Neue and Poppins from Google Fonts by default. Pass fontStylesheet="" on a site that already loads them.
--- EXPECTED BEHAVIOUR ---
Desktop: triggers are real <button> elements carrying aria-expanded and aria-controls; single-column panels that open from their triggers; one panel open at a time; Escape closes and returns focus to its trigger; a click outside closes; links inside a closed panel are out of the tab order.
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: absolute over the top of the page and scrolls away with it. It is designed to sit over a full-bleed hero that runs underneath it, so a page opening with such a hero reserves nothing. Any page that opens with something else must reserve the header's height, or its first heading renders underneath it. Its nearest positioned ancestor must be the page, not a wrapper, or it overlays the wrong thing.
11. On pages that open with a full-bleed hero, reserve nothing — the hero runs under the header by design. On every other page, reserve the header's height so the first heading is not covered, and re-check it 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 overlay 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/OverlayPillHeader.astro =====
---
/**
* OverlayPillHeader
*
* A site header that sits over the top of a hero: brand on the left, a
* translucent glass pill of links in the middle (one of which can open a
* dropdown), and a white pill call-to-action with a navy arrow disc on the
* right. Below 1024px the pill and the CTA fold into a menu button and a
* white card that drops under the bar.
*
* `overlay` (default true) positions the root absolutely at the top of its
* containing block, so it scrolls away with the page over whatever the page
* opens with. `overlay={false}` puts it back in the flow on a navy band.
*
* Reads --lx-* design tokens from any ancestor, with fallbacks, so it works
* alone and re-themes with a page.
*/
export interface OverlayPillHeaderLink {
label: string;
href: string;
}
export interface OverlayPillHeaderItem {
label: string;
/** Ignored when `children` is present: the item becomes a dropdown trigger. */
href?: string;
/** Renders the item as a real <button> that opens a single-column panel. */
children?: OverlayPillHeaderLink[];
}
export interface OverlayPillHeaderProps {
items?: OverlayPillHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image. */
logo?: { src: string; alt: string; width?: number; height?: number };
cta?: { label: string; href: string } | null;
/** Absolute over the hero (true) or in the flow on a navy band (false). */
overlay?: boolean;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends OverlayPillHeaderProps {}
/* No invented site: links, brand and CTA are all the caller's. Each block
renders only when its data is supplied; with none, nothing renders. */
const {
items = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
overlay = true,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Poppins:wght@400;500;600&display=swap",
id,
} = Astro.props;
/* Ids for aria-controls. Seeded from `id` when given, random otherwise, so a
second instance never collides with the first. */
const seed = id ? id : `oph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: OverlayPillHeaderItem) =>
Boolean(item.children?.some((c) => isCurrent(c.href)));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasContent = hasBrand || hasItems || Boolean(cta);
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class:list={["oph", overlay ? "oph--overlay" : "oph--flow"]}
id={id}
data-header-root
data-oph-root
>
<div class="oph-bar">
{hasBrand && (
<a class="oph-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="oph-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 114}
height={logo.height ?? 34}
/>
) : (
<>
<svg class="oph-mark" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<rect width="40" height="40" rx="11" fill="var(--lx-accent, #ff5f00)" />
<path
d="M7 17.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
fill="none"
stroke="#fff"
stroke-width="3.4"
stroke-linecap="round"
/>
<path
d="M7 25.5c3.2-3 6.4-3 9.6 0s6.4 3 9.6 0 5.2-2.6 6.8-1.4"
fill="none"
stroke="var(--lx-navy, #0a2540)"
stroke-width="3.4"
stroke-linecap="round"
/>
</svg>
<span class="oph-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="oph-nav" aria-label={navLabel}>
<ul class="oph-pill" role="list">
{
items.map((item, i) => {
if (item.children && item.children.length > 0) {
const panelId = `${seed}-panel-${i}`;
return (
<li class="oph-item oph-item--dd" data-oph-dd>
<button
type="button"
class="oph-link oph-trigger"
aria-expanded="false"
aria-controls={panelId}
data-oph-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="oph-chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<div class="oph-panel" id={panelId} data-oph-panel>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="oph-panel-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</div>
</li>
);
}
return (
<li class="oph-item">
<a
class="oph-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{
cta && (
<a class="oph-cta" href={cta.href}>
<span>{cta.label}</span>
<span class="oph-cta-disc" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)
}
{(hasItems || cta) && (
<button
type="button"
class="oph-burger"
aria-expanded="false"
aria-controls={menuId}
data-oph-burger
>
<span class="oph-sr">{menuLabel}</span>
<span class="oph-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
)}
</div>
{(hasItems || cta) && (
<nav class="oph-menu" id={menuId} aria-label={navLabel} data-oph-menu>
{hasItems && (
<ul class="oph-menu-list" role="list">
{
items.map((item) =>
item.children && item.children.length > 0 ? (
<li class="oph-menu-group">
<span class="oph-menu-label">{item.label}</span>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="oph-menu-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li>
<a
class="oph-menu-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
)}
{
cta && (
<a class="oph-cta oph-cta--menu" href={cta.href}>
<span>{cta.label}</span>
<span class="oph-cta-disc" aria-hidden="true">
<svg viewBox="0 0 24 24" focusable="false">
<path d="M5 12h13M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
</a>
)
}
</nav>
)}
</header>
)}
<style>
.oph {
--oph-navy: var(--lx-navy, #0a2540);
--oph-accent: var(--lx-accent, #ff5f00);
--oph-display: var(--lx-display, "Bebas Neue", Impact, "Arial Narrow", sans-serif);
--oph-body: var(--lx-body, Poppins, ui-sans-serif, system-ui, sans-serif);
--oph-dur: 220ms;
box-sizing: border-box;
width: 100%;
padding: 24px 30px;
font-family: var(--oph-body);
color: #fff;
z-index: 30;
}
.oph *,
.oph *::before,
.oph *::after {
box-sizing: border-box;
}
.oph ul {
margin: 0;
padding: 0;
list-style: none;
}
.oph--overlay {
position: absolute;
inset: 0 0 auto 0;
background: transparent;
}
.oph--flow {
position: relative;
background: var(--oph-navy);
}
.oph-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
max-width: 1200px;
min-height: 50px;
margin: 0 auto;
}
/* ---- brand ---- */
.oph-brand {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 8px;
flex: none;
}
.oph-mark {
width: 34px;
height: 34px;
flex: none;
}
.oph-wordmark {
font-family: var(--oph-display);
font-size: 34px;
line-height: 1;
letter-spacing: -0.02em;
text-transform: uppercase;
padding-top: 3px;
}
.oph-logo-img {
display: block;
height: 34px;
width: auto;
}
/* ---- the glass pill ---- */
.oph-nav {
display: none;
}
/* `.oph .oph-pill`, not `.oph-pill`: the `.oph ul` list reset would
otherwise outrank it and zero the pill's padding. */
.oph .oph-pill {
display: flex;
align-items: center;
gap: 40px;
height: 50px;
padding: 0 24px;
border-radius: 100px;
background: rgb(255 255 255 / 0.1);
border: 1px solid rgb(255 255 255 / 0.16);
-webkit-backdrop-filter: blur(12px);
backdrop-filter: blur(12px);
}
.oph-item {
position: relative;
}
.oph-link {
display: inline-flex;
align-items: center;
gap: 4px;
/* 14px type in a 44px target; the negative margin gives the space back
so the 40px rhythm between words is the reference's. */
padding: 15px 8px;
margin: -15px -8px;
border: 0;
border-radius: 100px;
background: none;
color: #fff;
font: 600 14px/14px var(--oph-body);
text-decoration: none;
cursor: pointer;
white-space: nowrap;
transition: color var(--oph-dur) ease;
}
.oph-link:hover,
.oph-link[aria-current="page"],
.oph-trigger[data-current] {
color: var(--oph-accent);
}
.oph-chev {
width: 14px;
height: 14px;
transition: transform var(--oph-dur) ease;
}
.oph-trigger[aria-expanded="true"] .oph-chev {
transform: rotate(180deg);
}
/* ---- dropdown panel ----
Without script the panel opens on hover and on focus-within, so every
link stays reachable. Once the controller has booted it owns the state,
and a closed panel is visibility: hidden — out of the tab order. */
.oph-panel {
position: absolute;
top: calc(100% + 26px);
left: -24px;
min-width: 220px;
padding: 20px;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
opacity: 0;
transform: translateY(-6px);
pointer-events: none;
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s linear var(--oph-dur);
z-index: 2;
}
/* A bridge over the gap between trigger and panel, so pointer travel
from one to the other does not cross dead space. */
.oph-panel::before {
content: "";
position: absolute;
inset: -26px 0 100% 0;
}
.oph-panel ul {
display: flex;
flex-direction: column;
gap: 4px;
}
.oph:not([data-oph-ready]) .oph-item--dd:hover .oph-panel,
.oph:not([data-oph-ready]) .oph-item--dd:focus-within .oph-panel,
.oph[data-oph-ready] .oph-item--dd[data-open] .oph-panel {
opacity: 1;
transform: none;
pointer-events: auto;
visibility: visible;
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s;
}
.oph[data-oph-ready] .oph-item--dd:not([data-open]) .oph-panel {
visibility: hidden;
}
.oph-panel-link {
display: flex;
align-items: center;
min-height: 36px;
padding: 0 10px;
margin: 0 -10px;
border-radius: 6px;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
white-space: nowrap;
transition: color var(--oph-dur) ease, background-color var(--oph-dur) ease;
}
.oph-panel-link:hover {
background: var(--lx-surface, #f6f6f6);
color: var(--oph-accent);
}
.oph-panel-link[aria-current="page"] {
color: var(--oph-accent);
}
/* ---- CTA ---- */
.oph-cta {
display: none;
align-items: center;
gap: 24px;
height: 50px;
padding: 5px 5px 5px 19px;
border-radius: 100px;
background: #fff;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
white-space: nowrap;
flex: none;
}
.oph-cta-disc {
display: grid;
place-items: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--oph-navy);
color: #fff;
overflow: hidden;
transition: background-color var(--oph-dur) ease;
}
.oph-cta-disc svg {
width: 24px;
height: 24px;
transition: transform var(--oph-dur) ease;
}
.oph-cta:hover .oph-cta-disc {
background: var(--oph-accent);
}
.oph-cta:hover .oph-cta-disc svg {
transform: rotate(-45deg);
}
/* ---- burger ---- */
.oph-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin-right: -7px;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: #fff;
cursor: pointer;
}
/* No script, no toggle: the menu card is simply shown open below the bar,
so a button that could do nothing is not offered. */
.oph:not([data-oph-ready]) .oph-burger {
display: none;
}
.oph-burger-lines {
position: relative;
display: block;
width: 22px;
height: 14px;
}
.oph-burger-lines span {
position: absolute;
left: 0;
width: 22px;
height: 2px;
border-radius: 1px;
background: currentColor;
transition: transform var(--oph-dur) ease, opacity var(--oph-dur) ease;
}
.oph-burger-lines span:nth-child(1) { top: 0; }
.oph-burger-lines span:nth-child(2) { top: 6px; }
.oph-burger-lines span:nth-child(3) { top: 12px; }
.oph[data-oph-open] .oph-burger-lines span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.oph[data-oph-open] .oph-burger-lines span:nth-child(2) { opacity: 0; }
.oph[data-oph-open] .oph-burger-lines span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* ---- mobile menu card ---- */
.oph-menu {
max-width: 1200px;
margin: 24px auto 0;
padding: 20px;
border-radius: 10px;
background: #fff;
color: var(--oph-navy);
box-shadow: 0 18px 40px rgb(10 37 64 / 0.18);
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s linear var(--oph-dur);
}
/* With script, the card hangs under the bar out of the flow, so opening
it covers the hero rather than pushing the page down. */
.oph[data-oph-ready] .oph-menu {
position: absolute;
top: calc(100% - 6px);
left: 30px;
right: 30px;
margin: 0;
}
.oph[data-oph-ready]:not([data-oph-open]) .oph-menu {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
pointer-events: none;
}
.oph[data-oph-ready][data-oph-open] .oph-menu {
transition:
opacity var(--oph-dur) ease,
transform var(--oph-dur) ease,
visibility 0s;
}
.oph-menu-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.oph-menu-group {
padding-bottom: 8px;
margin-bottom: 6px;
border-bottom: 1px solid rgb(10 37 64 / 0.14);
}
.oph-menu-label {
display: block;
padding: 6px 0 4px;
color: #555;
font: 500 12px/14px var(--oph-body);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.oph-menu-link {
display: flex;
align-items: center;
min-height: 44px;
color: var(--oph-navy);
font: 600 14px/14px var(--oph-body);
text-decoration: none;
border-radius: 6px;
}
.oph-menu-link[aria-current="page"] {
color: var(--oph-accent);
}
.oph-cta--menu {
display: flex;
justify-content: space-between;
margin-top: 14px;
background: var(--oph-navy);
color: #fff;
}
.oph-cta--menu .oph-cta-disc {
background: #fff;
color: var(--oph-navy);
}
/* ---- focus ---- */
.oph a:focus-visible,
.oph button:focus-visible {
outline: 2px solid var(--oph-accent);
outline-offset: 2px;
}
.oph-cta:focus-visible {
outline-color: #fff;
outline-offset: 3px;
}
.oph-menu a:focus-visible,
.oph-panel a:focus-visible {
outline-color: var(--oph-accent);
}
.oph-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- phone ---- */
@media (max-width: 809.98px) {
.oph {
padding: 20px;
}
.oph-mark {
width: 33px;
height: 33px;
}
.oph-wordmark {
font-size: 32px;
}
.oph-menu {
margin-top: 20px;
}
.oph[data-oph-ready] .oph-menu {
left: 20px;
right: 20px;
}
}
/* ---- desktop: the pill and CTA appear, the menu goes ---- */
@media (min-width: 1024px) {
.oph-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.oph-cta {
display: inline-flex;
}
.oph-burger,
.oph .oph-menu {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.oph {
--oph-dur: 1ms;
}
}
@media (forced-colors: active) {
.oph-pill,
.oph-panel,
.oph-menu {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* OverlayPillHeader controller.
*
* One closure per instance, every lookup scoped to that instance's root,
* every listener on an AbortController that is aborted before a
* client-side navigation swaps the document. No body scroll lock: the
* mobile menu is a card under the bar, not a sheet over the page.
*/
function initOverlayPillHeader(root: HTMLElement) {
/* Idempotent: a second boot over the same element is a no-op. */
if (root.dataset.ophReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-oph-burger]");
const menu = root.querySelector<HTMLElement>("[data-oph-menu]");
const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-oph-dd]")];
root.dataset.ophReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 1024px)");
/* ---- dropdowns: one open at a time, per instance ---- */
let openDd: HTMLElement | null = null;
const triggerOf = (dd: HTMLElement) =>
dd.querySelector<HTMLButtonElement>("[data-oph-trigger]");
function closeDropdown(returnFocus = false) {
if (!openDd) return;
const dd = openDd;
openDd = null;
dd.removeAttribute("data-open");
const trigger = triggerOf(dd);
trigger?.setAttribute("aria-expanded", "false");
if (returnFocus) trigger?.focus({ preventScroll: true });
}
function openDropdown(dd: HTMLElement) {
if (openDd === dd) return;
closeDropdown();
openDd = dd;
dd.setAttribute("data-open", "");
triggerOf(dd)?.setAttribute("aria-expanded", "true");
}
dropdowns.forEach((dd) => {
const trigger = triggerOf(dd);
trigger?.addEventListener(
"click",
() => (openDd === dd ? closeDropdown() : openDropdown(dd)),
{ signal },
);
/* Focus leaving the item entirely closes it, so Tab past the last
link does not leave a panel hanging open behind the focus. */
dd.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (openDd === dd && next && !dd.contains(next)) closeDropdown();
},
{ signal },
);
});
/* ---- mobile menu ---- */
let menuOpen = false;
function setMenu(next: boolean, returnFocus = false) {
if (!burger || !menu || menuOpen === next) return;
menuOpen = next;
root.toggleAttribute("data-oph-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
}
burger?.addEventListener("click", () => setMenu(!menuOpen), { signal });
/* Choosing a route closes the menu. */
menu?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
/* Escape is bound to the document, not the root: after a client-side
navigation focus sits outside the header, and Escape must still work. */
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape") return;
if (openDd) {
event.preventDefault();
const inside = openDd.contains(document.activeElement);
closeDropdown(inside || document.activeElement === document.body);
} else if (menuOpen) {
event.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
/* Outside click closes whichever is open. */
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (openDd && !openDd.contains(target)) closeDropdown();
if (menuOpen && !root.contains(target)) setMenu(false);
},
{ signal },
);
/* Crossing the breakpoint resets both: a menu open at phone width must
not be waiting open when the viewport narrows again later, and a
dropdown has no business staying open once its pill is hidden. */
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDropdown();
},
{ signal },
);
function destroy() {
closeDropdown();
setMenu(false);
controller.abort();
delete root.dataset.ophReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-oph-root]").forEach(initOverlayPillHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Registered once per module, so it cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/OverlayPillHeader.astro =====
===== FILE (supporting): src/components/OverlayPillHeader.md =====
# Overlay pill header
A site header laid over the top of the hero: brand on the left, a frosted
glass pill of links in the middle with one dropdown, and a white pill
call-to-action with a navy arrow disc on the right. Below 1024px the pill and
the CTA fold into a menu button that drops a white card under the bar.
Catalog page: `/components/overlay-pill-header`
## Purpose
The header for a page that opens on a dark, full-bleed hero photograph. It
paints nothing of its own in overlay mode, so the hero runs up behind it to
the top of the viewport, and it scrolls away with the page — it is not sticky
and not fixed.
Use something else if the header must stay on screen while scrolling, or if
your pages open on a light surface (pass `overlay={false}` for the odd page
that does).
## Files required
**`OverlayPillHeader.astro`** — one file, the whole component.
- No imports, no npm packages, no icon library, no framework.
- Scoped styles; reads `--lx-navy`, `--lx-accent`, `--lx-display`,
`--lx-body` and `--lx-surface` from any ancestor, with fallbacks.
- Loads Bebas Neue and Poppins from Google Fonts unless `fontStylesheet=""`.
- No images. The brand mark is inline SVG drawn for this library.
`OverlayPillHeader.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import OverlayPillHeader from "../components/OverlayPillHeader.astro";
---
<body>
<a class="skip" href="#main">Skip to main content</a>
<OverlayPillHeader
currentPath={Astro.url.pathname}
items={[
{ label: "Pages", children: [
{ label: "Home", href: "/" },
{ label: "Services", href: "/services" },
] },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
]}
cta={{ label: "Get a quote", href: "/contact" }}
/>
<main id="main"><!-- hero first --></main>
</body>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `OverlayPillHeaderItem[]?` | `[]` | Links in the pill. An item with `children` becomes a dropdown trigger. Empty: no pill and no menu list. |
| `currentPath` | `string?` | — | Pass `Astro.url.pathname`. Matching links get `aria-current="page"`. |
| `brandName` | `string?` | — | Wordmark text, and the brand link's name. No name and no `logo`: no brand link. |
| `brandHref` | `string?` | `"/"` | |
| `logo` | `{ src, alt, width?, height? }?` | — | Replaces the drawn mark and wordmark with an image. |
| `cta` | `{ label, href } \| null` | `null` | The white pill. Omitted or `null`: no CTA. |
| `overlay` | `boolean?` | `true` | Absolute over the hero, or in the flow on a navy band. |
| `navLabel` | `string?` | `"Primary"` | Accessible name for both navs. |
| `menuLabel` | `string?` | `"Menu"` | Accessible name for the menu button. |
| `fontStylesheet` | `string?` | Google Fonts URL | `""` turns it off. |
| `id` | `string?` | — | Root id. Also seeds the `aria-controls` ids. |
No business data is built in: brand, links and CTA are all yours. Each block
renders only when supplied, the menu button only when there are links or a
CTA to put in the card, and with none of the three the header renders
nothing. The catalog preview passes the demo "Tidewater" brand, link tree and
CTA as props.
```ts
interface OverlayPillHeaderItem {
label: string;
href?: string; // ignored when children exist
children?: { label: string; href: string }[];
}
```
## Behaviour
- **Position.** `overlay` (default) is `position: absolute; inset: 0 0 auto 0;
z-index: 30`. It scrolls away with the page.
- **Dropdown.** A real `<button>` with `aria-expanded` and `aria-controls`
opens one panel. Click, Enter or Space toggle it; Escape closes it and
returns focus to the trigger; a click outside closes it; tabbing out of it
closes it. One panel open at a time per instance. A closed panel is
`visibility: hidden`, so its links are out of the tab order.
- **Mobile menu** (below 1024px). Menu button with `aria-expanded` /
`aria-controls`. Escape closes and returns focus to the button, an outside
tap closes it, choosing a link closes it. Closed, the card is
`visibility: hidden`. Crossing to desktop width resets both the menu and
any open dropdown. No body scroll lock.
- **Without JavaScript.** The dropdown opens on hover and focus-within; the
mobile card is shown open under the bar and the (useless) menu button is
hidden. Every link stays reachable.
- **Lifecycle.** Boots on load and on `astro:page-load`, idempotent per root,
every listener on an `AbortController` aborted on `astro:before-swap`.
Escape and outside click are bound to `document`, so they work wherever
focus is after a client-side navigation. Never scrolls the window; focus
moves only in response to Escape.
## Accessibility
- Brand link named "{brandName} home"; the drawn mark is `aria-hidden`.
- Visible `:focus-visible` ring (accent, 2px, 2px offset) on every control.
- 44px targets throughout: the pill links are padded to 44px tall with the
space given back by negative margin, so the 40px rhythm is unchanged.
- Reduced motion collapses every transition to 1ms.
- Forced colours: the pill, panel and card get real borders.
## Approximations
- **Breakpoint.** The pill appears at 1024px. The reference has no tablet
capture; its pill (475px) plus logo and CTA do not fit at 810px, so 1024 is
a judgement call rather than a measurement.
- **Glass.** The reference measures a flat `rgba(255,255,255,.1)` fill; the
hairline border (16% white) and 12px backdrop blur are added per the brief
and read from the screenshot, not from computed styles.
- **Dropdown panel.** The reference's "All Page" panel was not captured open
at desktop. The white 10px-radius card mirrors its phone menu card.
- **Hover states** were not captured. Links go to the accent colour; the CTA
disc turns accent and its arrow rotates -45°.
- **Mobile menu.** The reference's card lists its pages flat; here a dropdown
item's children appear under a small uppercase label, and the CTA is
repeated at the bottom of the card (the reference drops it on phones).
- **Dropdown trigger** opens on click, not hover. Hover-to-open is a pointer
convenience that makes the state hard to keep in sync with `aria-expanded`.
- **Logo.** An original mark (two waves on an orange tile) and a Bebas Neue
wordmark stand in for the reference's logo.
===== END src/components/OverlayPillHeader.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- 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.