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 "Glass nav 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: Glass nav header
Library page: https://astro.baysixmedia.com/components/glass-nav-header/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: overlay
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
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` (52px; 48px below 810px) of its containing block. In the reference it sits inside the hero's positioned wrapper, under a 38px SignalTickerBar. On a page without a hero, reserve about 130px (with a ticker) or pass overlay={false}, which puts it in the flow on an ink band.
- Any positioned ancestor (relative, absolute, transform, filter, contain) becomes the containing block and the header pins to its top.
- Dark glass (rgba(8,17,15,.62) + backdrop-filter blur 22px) with white text: it needs a photo or dark hero under it. backdrop-filter degrades to the flat translucent fill where unsupported.
- The phone menu takes over below 960px, not 810px, because the bar content needs about 900px.
- The menu card is absolutely positioned 14px under the bar and covers the hero rather than pushing the page down. No body scroll lock. Escape (bound to the document), outside click, choosing a link, tabbing out and crossing to desktop all close it; Escape returns focus to the button.
- Without JavaScript the menu button is hidden and the card is shown open under the bar.
- z-index is 30. A hero with its own stacking context above that will cover the header.
- Pass currentPath={Astro.url.pathname} to get aria-current on the matching link; it is not inferred. The demo links are same-page hashes (#services, ...) plus /about.
- Loads Manrope and Inter from Google Fonts by default. Pass fontStylesheet="" on a site that already loads them.
--- 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: 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/GlassNavHeader.astro =====
---
/**
* GlassNavHeader
*
* A 1170px dark glass bar floating over a hero: logo mark and wordmark on
* the left, links in the middle, a "Contact us" text link and a lime quote
* pill on the right. Below 960px it folds to the logo and a real menu
* button that opens a glass card under the bar.
*
* `overlay` (default true) positions it absolutely at `top` inside its
* containing block — 52px by default, which clears a 38px ticker strip
* with the measured 14px gap. `overlay={false}` puts it in the flow on an
* ink band.
*
* Reads --nv-* tokens from any ancestor, with fallbacks.
*/
export interface GlassNavHeaderItem {
label: string;
href: string;
}
export interface GlassNavHeaderProps {
items?: GlassNavHeaderItem[];
/** 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 };
/** The plain text link beside the pill. `null` drops it. */
secondary?: { label: string; href: string } | null;
/** The lime pill. `null` drops it. */
cta?: { label: string; href: string } | null;
/** Absolute over the hero (true) or in the flow on an ink band (false). */
overlay?: boolean;
/** Offset from the top of the containing block in overlay mode, desktop and tablet. */
top?: string;
/** The same below 810px, where the ticker is 36px and the gap 12px. */
topPhone?: string;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends GlassNavHeaderProps {}
/* No invented site: links, brand, text link and pill are all the caller's.
Each block renders only when supplied; with none, nothing renders. */
const {
items = [],
currentPath,
brandName,
brandHref = "/",
logo,
secondary = null,
cta = null,
overlay = true,
top = "52px",
topPhone = "48px",
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
id,
} = Astro.props;
/* Ids for aria-controls, seeded from `id` or a random suffix so a second
instance never collides with the first. */
const seed = id ? id : `gnh-${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 style = `--gnh-top:${top};--gnh-top-phone:${topPhone}`;
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(secondary || cta);
const hasContent = hasBrand || hasMenu;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class:list={["gnh", overlay ? "gnh--overlay" : "gnh--flow"]}
id={id}
style={style}
data-header-root
data-gnh-root
>
<div class="gnh-inner">
<div class="gnh-bar">
{hasBrand && (
<a class="gnh-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="gnh-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 110}
height={logo.height ?? 26}
/>
) : (
<>
<svg class="gnh-mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
<rect x="4" y="4" width="7" height="24" rx="2" fill="var(--nv-lime, #c8ff38)" />
<rect x="21" y="4" width="7" height="24" rx="2" fill="#fff" />
<path d="M11 18.5 21 10v6.2L11 24.7z" fill="var(--nv-lime, #c8ff38)" />
</svg>
<span class="gnh-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="gnh-nav" aria-label={navLabel}>
<ul class="gnh-links" role="list">
{
items.map((item) => (
<li>
<a
class="gnh-link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
))
}
</ul>
</nav>
)}
{
(secondary || cta) && (
<div class="gnh-actions">
{secondary && (
<a
class="gnh-secondary"
href={secondary.href}
aria-current={isCurrent(secondary.href) ? "page" : undefined}
>
{secondary.label}
</a>
)}
{cta && (
<a class="gnh-cta" href={cta.href}>
<span>{cta.label}</span>
<svg class="gnh-arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M2.5 8h10M9 4.5 12.5 8 9 11.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)}
</div>
)
}
{hasMenu && (
<button
type="button"
class="gnh-burger"
aria-expanded="false"
aria-controls={menuId}
data-gnh-burger
>
<span class="gnh-sr">{menuLabel}</span>
<svg class="gnh-icon gnh-icon--open" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
<svg class="gnh-icon gnh-icon--close" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6 6l12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</button>
)}
</div>
{hasMenu && (
<nav class="gnh-menu" id={menuId} aria-label={navLabel} data-gnh-menu>
{(hasItems || secondary) && (
<ul class="gnh-menu-list" role="list">
{
items.map((item) => (
<li>
<a
class="gnh-menu-link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
))
}
{
secondary && (
<li>
<a
class="gnh-menu-link"
href={secondary.href}
aria-current={isCurrent(secondary.href) ? "page" : undefined}
>
{secondary.label}
</a>
</li>
)
}
</ul>
)}
{cta && <a class="gnh-menu-cta" href={cta.href}>{cta.label}</a>}
</nav>
)}
</div>
</header>
)}
<style>
.gnh {
--gnh-ink: var(--nv-ink, #08110f);
--gnh-lime: #b7ff39;
--gnh-font: var(--nv-font, Manrope, ui-sans-serif, system-ui, sans-serif);
--gnh-dur: 0.3s;
--gnh-ease: cubic-bezier(0.22, 1, 0.36, 1);
box-sizing: border-box;
width: 100%;
padding: 0 20px;
font-family: var(--gnh-font);
color: #fff;
z-index: 30;
}
.gnh *,
.gnh *::before,
.gnh *::after {
box-sizing: border-box;
}
.gnh ul {
margin: 0;
padding: 0;
list-style: none;
}
.gnh--overlay {
position: absolute;
top: var(--gnh-top, 52px);
left: 0;
right: 0;
background: transparent;
}
.gnh--flow {
position: relative;
padding-top: 14px;
padding-bottom: 14px;
background: var(--gnh-ink);
}
.gnh-inner {
position: relative;
max-width: 1170px;
margin: 0 auto;
}
/* ---- the glass bar ---- */
.gnh-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 58px;
padding: 0 12px;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 18px;
background: rgb(8 17 15 / 0.66);
box-shadow: 0 16px 36px rgb(0 0 0 / 0.22);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
}
/* ---- brand ---- */
.gnh-brand {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 8px;
flex: none;
}
.gnh-mark {
display: block;
width: 26px;
height: 26px;
flex: none;
}
.gnh-wordmark {
font-size: 15px;
font-weight: 800;
line-height: 15px;
letter-spacing: -0.04em;
text-transform: uppercase;
}
.gnh-logo-img {
display: block;
height: 26px;
width: auto;
}
/* ---- links (desktop) ---- */
.gnh-nav,
.gnh-actions {
display: none;
}
.gnh .gnh-links {
display: flex;
align-items: center;
gap: 30px;
}
.gnh-link {
display: inline-flex;
align-items: center;
/* 14px type in a 44px target; the negative margin hands the space
back so the measured 30px rhythm holds. */
padding: 15px 4px;
margin: -15px -4px;
border-radius: 8px;
color: rgb(255 255 255 / 0.76);
font-size: 14px;
font-weight: 700;
line-height: 14px;
text-decoration: none;
white-space: nowrap;
}
/* No hover change was measured on these links; the current page is
marked in full white with an underline. */
.gnh-link[aria-current="page"] {
color: #fff;
text-decoration: underline;
text-decoration-color: var(--gnh-lime);
text-decoration-thickness: 2px;
text-underline-offset: 6px;
}
/* ---- actions ---- */
.gnh-actions {
align-items: center;
gap: 10px;
flex: none;
}
.gnh-secondary {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 4px;
border-radius: 8px;
color: #fff;
font-size: 13px;
font-weight: 800;
line-height: 13px;
text-decoration: none;
white-space: nowrap;
}
.gnh-cta {
display: inline-flex;
align-items: center;
gap: 6px;
height: 46px;
padding: 0 18px;
border-radius: 100px;
background: var(--gnh-lime);
color: var(--gnh-ink);
font-size: 13px;
font-weight: 800;
line-height: 13px;
text-decoration: none;
white-space: nowrap;
/* The hover grows lime around the label with a spread shadow in the
pill's own colour, so the pill swells without reflowing the bar. */
box-shadow: 0 0 0 0 var(--gnh-lime);
transition: box-shadow var(--gnh-dur) var(--gnh-ease), background-color var(--gnh-dur) var(--gnh-ease);
}
.gnh-cta:hover {
box-shadow: 0 0 0 5px var(--gnh-lime);
}
.gnh-arrow {
width: 14px;
height: 14px;
flex: none;
}
/* ---- menu button ---- */
.gnh-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 12px;
background: rgb(255 255 255 / 0.1);
color: #fff;
cursor: pointer;
flex: none;
transition: background-color var(--gnh-dur) var(--gnh-ease);
}
.gnh-burger:hover {
background: rgb(255 255 255 / 0.16);
}
.gnh-icon {
grid-area: 1 / 1;
width: 24px;
height: 24px;
}
.gnh-burger[aria-expanded="true"] .gnh-icon--open,
.gnh-burger[aria-expanded="false"] .gnh-icon--close {
display: none;
}
/* No script, no toggle: the card is shown open under the bar instead, so
a button that could do nothing is not offered. */
.gnh:not([data-gnh-ready]) .gnh-burger {
display: none;
}
/* ---- menu card ---- */
.gnh-menu {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 14px;
padding: 14px 20px 20px;
border: 1px solid rgb(255 255 255 / 0.1);
border-radius: 24px;
background: rgb(8 17 15 / 0.72);
box-shadow: 0 16px 36px rgb(0 0 0 / 0.22);
-webkit-backdrop-filter: blur(22px);
backdrop-filter: blur(22px);
}
/* With script the card hangs under the bar out of the flow, covering the
hero rather than pushing the page down, and a closed card is
visibility: hidden — out of the tab order and the accessibility tree. */
.gnh[data-gnh-ready] .gnh-menu {
position: absolute;
top: calc(100% + 14px);
left: 0;
right: 0;
margin: 0;
transition:
opacity 0.22s var(--gnh-ease),
transform 0.22s var(--gnh-ease),
visibility 0s linear 0.22s;
}
.gnh[data-gnh-ready]:not([data-gnh-open]) .gnh-menu {
opacity: 0;
transform: translateY(-6px);
visibility: hidden;
pointer-events: none;
}
.gnh[data-gnh-ready][data-gnh-open] .gnh-menu {
transition:
opacity 0.22s var(--gnh-ease),
transform 0.22s var(--gnh-ease),
visibility 0s;
}
.gnh-menu-list {
display: flex;
flex-direction: column;
}
.gnh-menu-link {
display: flex;
align-items: center;
min-height: 44px;
border-radius: 8px;
color: rgb(255 255 255 / 0.88);
font-size: 15px;
font-weight: 700;
line-height: 15px;
text-decoration: none;
}
.gnh-menu-link[aria-current="page"] {
color: var(--gnh-lime);
}
.gnh-menu-cta {
display: flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 14px 16px;
border-radius: 12px;
background: linear-gradient(100deg, #c8ff38 0%, #73f5df 52%, #ffb86b 100%);
color: var(--gnh-ink);
font-size: 15px;
font-weight: 800;
line-height: 15px;
text-decoration: none;
}
/* ---- focus ---- */
.gnh a:focus-visible,
.gnh button:focus-visible {
outline: 2px solid var(--gnh-lime);
outline-offset: 2px;
}
.gnh-link:focus-visible,
.gnh-secondary:focus-visible {
color: #fff;
}
.gnh-cta:focus-visible,
.gnh-menu-cta:focus-visible {
outline-color: #fff;
outline-offset: 3px;
}
.gnh-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: the reference's own offsets ---- */
@media (max-width: 809.98px) {
.gnh {
padding: 0 12px;
}
.gnh--overlay {
top: var(--gnh-top-phone, 48px);
}
.gnh--flow {
padding-top: 12px;
padding-bottom: 12px;
}
}
/* ---- desktop: the links and actions appear, the menu goes ---- */
@media (min-width: 960px) {
.gnh-bar {
height: 64px;
padding: 0 18px;
border-radius: 22px;
background: rgb(8 17 15 / 0.62);
}
.gnh-wordmark {
font-size: 18px;
line-height: 18px;
}
.gnh-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.gnh-actions {
display: flex;
}
.gnh-burger,
.gnh .gnh-menu {
display: none;
}
}
/* Between 960px and 1100px the centred links would run into the
actions; let them sit in the flow between the brand and the actions. */
@media (min-width: 960px) and (max-width: 1099.98px) {
.gnh-nav {
position: static;
transform: none;
}
.gnh .gnh-links {
gap: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.gnh {
--gnh-dur: 1ms;
}
.gnh[data-gnh-ready] .gnh-menu {
transform: none !important;
}
}
@media (forced-colors: active) {
.gnh-bar,
.gnh-menu {
border-color: CanvasText;
}
}
</style>
<script>
/**
* GlassNavHeader controller: the phone menu only.
*
* One closure per instance, every lookup scoped to its root, every
* listener on an AbortController aborted before a client-side navigation
* swaps the document. No body scroll lock: the menu is a card under the
* bar, not a sheet over the page. Never moves focus except in response
* to the user closing the menu.
*/
function initGlassNavHeader(root: HTMLElement) {
if (root.dataset.gnhReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-gnh-burger]");
const menu = root.querySelector<HTMLElement>("[data-gnh-menu]");
if (!burger || !menu) return;
root.dataset.gnhReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 960px)");
let open = false;
function setMenu(next: boolean, returnFocus = false) {
if (open === next) return;
open = next;
root.toggleAttribute("data-gnh-open", next);
burger!.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger!.focus({ preventScroll: true });
}
burger.addEventListener("click", () => setMenu(!open), { signal });
/* Choosing a destination closes the menu. */
menu.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
/* Escape on the document, not the root: after a client-side navigation
focus can sit outside the header and Escape must still close it. */
document.addEventListener(
"keydown",
(event) => {
if (event.key !== "Escape" || !open) return;
event.preventDefault();
setMenu(false, true);
},
{ signal },
);
/* Outside click closes. */
document.addEventListener(
"pointerdown",
(event) => {
if (open && !root.contains(event.target as Node)) setMenu(false);
},
{ signal },
);
/* Focus leaving the header entirely (Tab past the last link) closes it,
so an open card never hangs behind the focus. */
root.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (open && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
/* Crossing to desktop resets: the menu must not be waiting open the next
time the viewport narrows. */
desktop.addEventListener("change", () => setMenu(false), { signal });
function destroy() {
setMenu(false);
controller.abort();
delete root.dataset.gnhReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-gnh-root]").forEach(initGlassNavHeader);
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/GlassNavHeader.astro =====
===== FILE (supporting): src/components/GlassNavHeader.md =====
# Glass nav header
A 1170px dark glass bar floating over a hero: logo mark and wordmark on the
left, links in the middle, a "Contact us" text link and a lime "Get a quote →"
pill on the right. Below 960px it folds to the logo and a menu button that
opens a glass card under the bar.
Catalog page: `/components/glass-nav-header`
## Purpose
The site header for a page that opens on a full-bleed photo hero, usually
under a `SignalTickerBar`. Static navigation — links only, no dropdowns.
Use `OverlayPillHeader` or another header if you need panels.
## Files required
**`GlassNavHeader.astro`** — one file: markup, scoped styles and a small
script for the phone menu. No imports, no npm packages, no images.
`GlassNavHeader.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import SignalTickerBar from "../components/SignalTickerBar.astro";
import GlassNavHeader from "../components/GlassNavHeader.astro";
---
<div class="hero-wrap" style="position: relative">
<SignalTickerBar />
<GlassNavHeader
currentPath={Astro.url.pathname}
brandName="Your Business"
items={[
{ label: "Services", href: "/services" },
{ label: "About", href: "/about" },
]}
secondary={{ label: "Contact us", href: "/contact" }}
cta={{ label: "Get a quote", href: "/contact" }}
/>
<!-- the hero runs underneath -->
</div>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `{ label, href }[]` | `[]` | The links, in the bar and the phone menu. Empty: no link row. |
| `currentPath` | `string` | — | Pass `Astro.url.pathname`; matching links get `aria-current="page"`. |
| `brandName` | `string` | — | Wordmark text and the home link's accessible 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 (26px tall). |
| `secondary` | `{ label, href } \| null` | `null` | The text link beside the pill. Also the last menu link. |
| `cta` | `{ label, href } \| null` | `null` | The lime pill; the gradient button in the phone menu. |
| `overlay` | `boolean` | `true` | Absolute over the hero, or in the flow on an ink band. |
| `top` | `string` | `"52px"` | Overlay offset at 810px and up. Clears a 38px ticker plus 14px. |
| `topPhone` | `string` | `"48px"` | Overlay offset below 810px (36px ticker plus 12px). |
| `navLabel` | `string` | `"Primary"` | `aria-label` of both navs. |
| `menuLabel` | `string` | `"Menu"` | Menu button's accessible name. |
| `fontStylesheet` | `string` | Manrope + Inter on Google Fonts | `""` skips the `<link>`. |
| `id` | `string` | — | Root id. Also seeds the menu's id. |
No business data is built in: brand, links, text link and pill are all
yours. Each renders only when supplied, the menu button only when there is
something to put in the menu, and with nothing at all the header renders
nothing. The catalog preview passes the demo "Halcyon" brand, links and
actions as props.
## Behaviour
- **Bar.** 1170px max, 64px tall, 22px radius, `rgba(8,17,15,.62)` fill,
`blur(22px)` backdrop, a `rgba(255,255,255,.1)` hairline and
`0 16px 36px rgba(0,0,0,.22)` shadow. Links 14px/700 at 76% white, 30px
apart, centred on the bar. Phone: 58px, 18px radius, 66% fill, 12px gutters.
- **Quote pill hover.** At rest the pill is lime `#b7ff39` with 18px side
padding (as the screenshots and the rest-state styles show). On hover the
lime grows 5px around it — a spread `box-shadow` in the pill's own colour,
eased over 0.3s — so the pill swells without reflowing the bar. See
Approximations for why this is not the literal hover reading.
- **Nav, Contact us.** No hover change, as measured. Focus shows a 2px lime
ring and full white text. The current page is full white with a lime
underline.
- **Phone menu.** The menu button (hamburger ↔ ×) toggles a glass card 14px
under the bar: links at 15px/700, then the Contact link, then a full-width
gradient "Get a quote" button. It fades and drops 6px in over 0.22s.
Choosing a link, clicking outside, pressing Escape, tabbing out of the
header, or widening past 960px closes it. Escape returns focus to the
button. No body scroll lock: the card covers the hero, it does not push the
page down.
- **Reduced motion.** The card and pill transitions become instant.
- **Without JavaScript.** The menu button is hidden and the card is shown
open under the bar, so every link is reachable.
## Accessibility
- `<header>` with `<nav aria-label>` for the bar links and a second one for
the phone menu (only one is ever displayed).
- The menu button is a real `<button>` with `aria-expanded` and
`aria-controls`. A closed card is `visibility: hidden`, so its links are out
of the tab order and the accessibility tree.
- Every target is at least 44px tall: bar links carry 15px of invisible
padding each side vertically; menu links are 44px rows.
- Focus never moves on load; it moves only to return to the menu button when
the user closes the menu with Escape.
## Integration notes
- Absolutely positioned at `top` inside its containing block. Put it inside
the hero's positioned wrapper (as the reference does), or as the first
thing in `<body>` after the ticker. On a page without a hero, reserve its
height (about 130px with a ticker) or pass `overlay={false}`.
- Any positioned ancestor becomes the containing block; the header pins to
its top.
- The glass needs something under it. Over a white page, pass
`overlay={false}` (ink band).
- `backdrop-filter` degrades to the flat translucent fill where unsupported,
still legible over a dark hero.
- z-index 30. Lower a hero that creates a higher stacking context.
- `data-header-root` and `data-gnh-root` on the root. The script boots on
load and on `astro:page-load`, is idempotent per root, and aborts every
listener on `astro:before-swap`.
## Images and credits
None. The mark is an original inline SVG.
## Approximations
- **Quote pill hover.** The brief's hover table reads "bg transparent → lime,
padding 0 → 18px". Both screenshots and the rest-state computed styles show
the pill already lime with 18px padding, and a transparent rest would put
ink text on dark glass. So the measured rest state is kept and the "grows
around the label" hover is reproduced as a 5px lime spread. Confirm against
the live page.
- **Breakpoint.** The phone menu takes over below 960px, not Framer's 810px:
the bar's content needs about 900px. Between 960px and 1100px the links sit
in the flow between brand and actions rather than dead-centre. Tablet was
not captured.
- **Touch targets.** The reference menu button is 40px and the menu rows are
21px apart at 8px gaps; here the button is 44px and rows are 44px, so the
open card is taller than the reference's 224px.
- **Menu button hover** (a slightly lighter fill) is this library's; none was
measured.
- **Gradient quote button** colours (lime → aqua → orange) are read from the
phone screenshot, not from computed styles.
- The hairline border on the bar is from the screenshot; the dump reports none.
===== END src/components/GlassNavHeader.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.