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 "Scroll 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: Scroll pill header
Library page: https://astro.baysixmedia.com/components/scroll-pill-header/
Navigation mode: dropdown — single-column panels that open from their triggers
Position mode: fixed
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:
- position: fixed at top 20px (12px below 810px), z-index 40. It reserves no space: the first section must leave room at its top (TagHeadlineHero and TagTitleBanner already do). On a page that starts with anything else, add about 110px of top padding or pass solid.
- Transparent state is a 10% white wash with white type, meant for a dark photo hero. After `threshold` px of scroll (8 by default) it turns solid white with navy type. Pass `solid` on pages with a light top.
- The dropdown opens on hover for a mouse and on click or Enter/Space for everyone else. Escape closes it and returns focus to its trigger; so does moving focus or the pointer out.
- The phone menu (below 1000px) flattens dropdown children into the list. Escape (bound to the document), outside click, choosing a link, tabbing out and crossing to desktop all close it.
- Without JavaScript the bar is solid white, the menu button is hidden and the phone card is shown open under the bar; dropdowns open on :hover and :focus-within.
- Pass currentPath={Astro.url.pathname} for aria-current; a dropdown trigger is marked current when one of its children is.
- Loads Outfit, Noto Sans and Inter 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: 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/ScrollPillHeader.astro =====
---
/**
* ScrollPillHeader
*
* A 1350px pill bar fixed 20px from the top of the viewport. Over the hero it
* is a faint white wash with white type; once the page scrolls past
* `threshold` it turns solid white with navy type. Logo on the left, links in
* the middle (an item with `children` opens a small dropdown card), an orange
* "Get a Quote" pill on the right. Below 1000px it folds to the logo and a
* round menu button that opens a white card under the bar.
*
* Reads the --tw-* tokens from any ancestor, with fallbacks.
*/
export interface ScrollPillHeaderLink {
label: string;
href: string;
}
export interface ScrollPillHeaderItem {
label: string;
/** Required for a plain link; ignored on a dropdown trigger. */
href?: string;
/** Turns the item into a dropdown trigger. */
children?: ScrollPillHeaderLink[];
/**
* Mega-menu shaped data from other headers in this library. The links of
* every group are flattened into one dropdown list; headings are dropped.
*/
groups?: { heading?: string; links: ScrollPillHeaderLink[] }[];
}
export interface ScrollPillHeaderProps {
items?: ScrollPillHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark. Give a light and a dark version. */
logo?: { src: string; srcOnDark?: string; alt: string; width?: number; height?: number };
/** The orange pill. `null` drops it. */
cta?: ScrollPillHeaderLink | null;
/** Always solid white, for pages whose top is not a dark hero. */
solid?: boolean;
/** Scroll distance in px after which the bar turns solid. */
threshold?: number;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ScrollPillHeaderProps {}
/* 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: rawItems = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
solid = false,
threshold = 8,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@500;600;700;800;900&family=Noto+Sans:wght@400;500;600;700&family=Inter:wght@500;600;700&display=swap",
id,
} = Astro.props;
const items = rawItems.map((item) => ({
label: item.label,
href: item.href ?? "#",
children: item.children ?? item.groups?.flatMap((g) => g.links),
}));
const seed = id ? id : `sph-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safeHref = (href?: string) => (href && SAFE.test(href.trim()) ? href.trim() : "#");
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) => Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: { children?: ScrollPillHeaderLink[] }) => (item.children ?? []).some((c) => isCurrent(c.href));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(cta);
const hasContent = hasBrand || hasMenu;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class="sph"
id={id}
data-header-root
data-sph-root
data-sph-threshold={threshold}
data-solid={solid ? "" : undefined}
>
<div class="sph-bar">
{hasBrand && (
<a class="sph-brand" href={safeHref(brandHref)} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<>
<img class="sph-logo sph-logo--light" src={logo.src} alt={logo.alt} width={logo.width ?? 167} height={logo.height ?? 43} />
{logo.srcOnDark && (
<img class="sph-logo sph-logo--dark" src={logo.srcOnDark} alt="" width={logo.width ?? 167} height={logo.height ?? 43} />
)}
</>
) : (
<>
<svg class="sph-mark" viewBox="0 0 40 40" aria-hidden="true">
<path d="M6 21a14 14 0 0 1 28 0h-6a8 8 0 0 0-16 0Z" fill="currentColor" />
<path d="M20 17 11 30h6v7h6v-7h6Z" fill="currentColor" />
</svg>
<span class="sph-word">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="sph-nav" aria-label={navLabel}>
<ul class="sph-list">
{
items.map((item, i) =>
item.children?.length ? (
<li class="sph-item sph-item--drop" data-sph-drop>
<button
type="button"
class="sph-link sph-trigger"
aria-expanded="false"
aria-controls={`${seed}-drop-${i}`}
data-sph-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="sph-chev" viewBox="0 0 12 12" aria-hidden="true">
<path d="M2 4.25 6 8l4-3.75" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</button>
<ul class="sph-drop" id={`${seed}-drop-${i}`} data-sph-panel>
{item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li class="sph-item">
<a class="sph-link" href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>
),
)
}
</ul>
</nav>
)}
{
cta && (
<a class="sph-cta" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
{hasMenu && (
<button type="button" class="sph-burger" aria-expanded="false" aria-controls={menuId} data-sph-burger>
<span class="sph-sr">{menuLabel}</span>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="9.25" fill="none" stroke="currentColor" stroke-width="1.5" />
<path d="M12 7.5v9M7.5 12h9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
)}
</div>
{hasMenu && (
<div class="sph-menu" id={menuId} data-sph-menu>
{hasItems && (
<ul>
{
items.flatMap((item) =>
item.children?.length
? item.children.map((child) => (
<li>
<a href={safeHref(child.href)} aria-current={isCurrent(child.href) ? "page" : undefined}>
{child.label}
</a>
</li>
))
: [
<li>
<a href={safeHref(item.href)} aria-current={isCurrent(item.href) ? "page" : undefined}>
{item.label}
</a>
</li>,
],
)
}
</ul>
)}
{
cta && (
<a class="sph-cta sph-cta--menu" href={safeHref(cta.href)}>
<span>{cta.label}</span>
<span class="sph-cta-ic" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M8 5l10 7-10 7 3-7Z" fill="currentColor" /></svg>
</span>
</a>
)
}
</div>
)}
</header>
)}
<style>
.sph {
--sph-navy: var(--tw-navy, #0b4654);
--sph-deep: var(--tw-navy-deep, #052931);
--sph-accent: var(--tw-accent, #ff6d45);
--sph-body: var(--tw-body, "Noto Sans", ui-sans-serif, system-ui, sans-serif);
--sph-display: var(--tw-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--sph-ease: cubic-bezier(0.44, 0, 0.56, 1);
--sph-ink: #fff;
position: fixed;
top: 12px;
left: 50%;
z-index: 40;
width: min(1350px, calc(100% - 24px));
transform: translateX(-50%);
font-family: var(--sph-body);
}
.sph *,
.sph *::before,
.sph *::after {
box-sizing: border-box;
}
.sph[data-scrolled],
.sph[data-solid],
.sph[data-sph-open] {
--sph-ink: var(--sph-navy);
}
.sph-bar {
position: relative;
display: flex;
align-items: center;
gap: 16px;
height: 64px;
padding: 0 12px 0 16px;
border-radius: 999px;
background: rgb(255 255 255 / 0.1);
color: var(--sph-ink);
/* No colour transition here: the links carry their own, and a second
one on the parent would chain behind it. */
transition:
background-color 0.4s var(--sph-ease),
box-shadow 0.4s var(--sph-ease);
}
.sph[data-scrolled] .sph-bar,
.sph[data-solid] .sph-bar,
.sph[data-sph-open] .sph-bar {
background: #fff;
box-shadow: 0 0 0 1px rgb(11 70 84 / 0.08);
}
.sph-brand {
display: inline-flex;
align-items: center;
gap: 8px;
margin-right: auto;
color: inherit;
text-decoration: none;
border-radius: 8px;
}
.sph-mark {
width: 32px;
height: 32px;
color: var(--sph-accent);
}
.sph-word {
font: 500 26px/1 var(--sph-display);
letter-spacing: -0.5px;
}
.sph-logo {
display: block;
width: auto;
height: 34px;
}
.sph-logo--dark {
display: none;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark {
display: block;
}
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--dark ~ .sph-logo--light,
.sph:not([data-scrolled]):not([data-solid]):not([data-sph-open]) .sph-logo--light:has(+ .sph-logo--dark) {
display: none;
}
.sph-nav,
.sph > .sph-bar > .sph-cta {
display: none;
}
.sph-list {
display: flex;
align-items: center;
gap: 35px;
margin: 0;
padding: 0;
list-style: none;
}
.sph-item {
position: relative;
}
.sph-link {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 8px 0;
border: 0;
background: none;
color: inherit;
font: 500 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
cursor: pointer;
transition: color 0.3s var(--sph-ease);
}
.sph-link:hover,
.sph-item--drop[data-open] .sph-trigger,
.sph-link[aria-current="page"],
.sph-trigger[data-current] {
color: var(--sph-accent);
}
.sph-chev {
width: 14px;
height: 14px;
transition: transform 0.3s var(--sph-ease);
}
.sph-item--drop[data-open] .sph-chev {
transform: rotate(180deg);
}
.sph-drop {
position: absolute;
top: calc(100% + 16px);
left: 0;
min-width: 290px;
margin: 0;
padding: 6px 10px;
list-style: none;
border-radius: 10px;
background: #fff;
box-shadow: 0 12px 30px rgb(5 41 49 / 0.14);
opacity: 0;
visibility: hidden;
transform: translateY(6px);
transition:
opacity 0.25s var(--sph-ease),
transform 0.25s var(--sph-ease),
visibility 0s linear 0.25s;
}
/* A bridge over the gap, so moving the pointer from the trigger to the
card does not cross empty space and close it. */
.sph-drop::before {
content: "";
position: absolute;
inset: -18px 0 100% 0;
}
.sph-item--drop[data-open] .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
.sph-drop li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-drop a {
display: block;
padding: 10px 0;
color: var(--sph-navy);
font: 500 16px/1.4 var(--sph-body);
text-transform: uppercase;
text-decoration: none;
transition: color 0.3s var(--sph-ease);
}
.sph-drop a:hover,
.sph-drop a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-cta {
display: inline-flex;
align-items: center;
gap: 12px;
height: 50px;
padding: 0 8px 0 25px;
border-radius: 999px;
background: var(--sph-accent);
color: #fff;
font: 400 16px/25.6px var(--sph-body);
letter-spacing: -0.32px;
text-decoration: none;
white-space: nowrap;
transition: background-color 0.3s var(--sph-ease);
}
.sph-cta:hover {
background: var(--sph-deep);
}
.sph-cta-ic {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
background: #fff;
color: var(--sph-accent);
transition: transform 0.3s var(--sph-ease);
}
.sph-cta-ic svg {
width: 18px;
height: 18px;
}
.sph-cta:hover .sph-cta-ic {
transform: rotate(-45deg);
}
.sph-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
color: var(--sph-accent);
cursor: pointer;
}
.sph-burger svg {
width: 26px;
height: 26px;
transition: transform 0.3s var(--sph-ease);
}
.sph[data-sph-open] .sph-burger svg {
transform: rotate(45deg);
}
.sph-menu {
position: absolute;
top: calc(100% + 10px);
left: 0;
right: 0;
display: none;
padding: 12px 20px 20px;
border-radius: 20px;
background: #fff;
box-shadow: 0 16px 40px rgb(5 41 49 / 0.18);
}
.sph[data-sph-open] .sph-menu {
display: block;
}
.sph-menu ul {
margin: 0 0 16px;
padding: 0;
list-style: none;
}
.sph-menu li + li {
border-top: 1px solid rgb(11 70 84 / 0.1);
}
.sph-menu a:not(.sph-cta) {
display: block;
padding: 12px 0;
color: var(--sph-navy);
font: 500 17px/1.4 var(--sph-body);
text-decoration: none;
}
.sph-menu a:not(.sph-cta):hover,
.sph-menu a[aria-current="page"] {
color: var(--sph-accent);
}
.sph-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
.sph a:focus-visible,
.sph button:focus-visible {
outline: 2px solid var(--sph-accent);
outline-offset: 3px;
}
/* No JavaScript: no menu button, and the phone card is shown under the bar
as a plain list so every link stays reachable. */
.sph:not([data-sph-ready]) .sph-burger {
display: none;
}
.sph:not([data-sph-ready]) .sph-menu {
display: block;
}
.sph:not([data-sph-ready]) .sph-bar {
background: #fff;
--sph-ink: var(--sph-navy);
color: var(--sph-navy);
}
.sph:not([data-sph-ready]) .sph-item--drop:focus-within .sph-drop,
.sph:not([data-sph-ready]) .sph-item--drop:hover .sph-drop {
opacity: 1;
visibility: visible;
transform: none;
transition-delay: 0s;
}
@media (min-width: 810px) {
.sph {
top: 20px;
width: min(1350px, calc(100% - 60px));
}
.sph-bar {
height: 80px;
padding: 0 16px 0 22px;
}
}
@media (min-width: 1000px) {
.sph {
width: min(1350px, calc(100% - 90px));
}
.sph-bar {
height: 90px;
padding: 0 20px 0 22px;
}
.sph-nav {
display: block;
position: absolute;
left: 50%;
transform: translateX(-50%);
}
.sph > .sph-bar > .sph-cta {
display: inline-flex;
}
.sph-burger,
.sph:not([data-sph-ready]) .sph-menu,
.sph .sph-menu {
display: none !important;
}
.sph-mark {
width: 38px;
height: 38px;
}
.sph-word {
font-size: 32px;
}
.sph-logo {
height: 43px;
}
}
@media (prefers-reduced-motion: reduce) {
.sph-bar,
.sph-link,
.sph-chev,
.sph-drop,
.sph-cta,
.sph-cta-ic,
.sph-burger svg {
transition: none;
}
.sph-cta:hover .sph-cta-ic {
transform: none;
}
}
@media (forced-colors: active) {
.sph-bar,
.sph-drop,
.sph-menu {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* ScrollPillHeader controller: the scroll swap, the desktop dropdowns and
* the phone menu. One closure per instance, every lookup scoped to its
* root, every listener on one AbortController aborted before a client-side
* navigation swaps the document. No body scroll lock.
*/
function initScrollPillHeader(root: HTMLElement) {
if (root.dataset.sphReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-sph-burger]");
const menu = root.querySelector<HTMLElement>("[data-sph-menu]");
root.dataset.sphReady = "";
const controller = new AbortController();
const { signal } = controller;
const threshold = Number(root.dataset.sphThreshold) || 8;
const desktop = window.matchMedia("(min-width: 1000px)");
const hoverable = window.matchMedia("(hover: hover)");
/* ---- scroll swap ---- */
let ticking = false;
const applyScroll = () => {
ticking = false;
root.toggleAttribute("data-scrolled", window.scrollY > threshold);
};
applyScroll();
window.addEventListener(
"scroll",
() => {
if (ticking) return;
ticking = true;
requestAnimationFrame(applyScroll);
},
{ passive: true, signal },
);
/* ---- dropdowns ---- */
const drops = [...root.querySelectorAll<HTMLElement>("[data-sph-drop]")];
const setDrop = (drop: HTMLElement, open: boolean, returnFocus = false) => {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger || drop.hasAttribute("data-open") === open) return;
drop.toggleAttribute("data-open", open);
trigger.setAttribute("aria-expanded", String(open));
if (!open && returnFocus) trigger.focus({ preventScroll: true });
};
const closeDrops = (except?: HTMLElement) => drops.forEach((d) => d !== except && setDrop(d, false));
for (const drop of drops) {
const trigger = drop.querySelector<HTMLButtonElement>("[data-sph-trigger]");
if (!trigger) continue;
trigger.addEventListener(
"click",
() => {
const next = !drop.hasAttribute("data-open");
closeDrops(drop);
setDrop(drop, next);
},
{ signal },
);
drop.addEventListener(
"pointerenter",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) {
closeDrops(drop);
setDrop(drop, true);
}
},
{ signal },
);
drop.addEventListener(
"pointerleave",
(e) => {
if (e.pointerType === "mouse" && hoverable.matches) setDrop(drop, false);
},
{ signal },
);
drop.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (next && !drop.contains(next)) setDrop(drop, false);
},
{ signal },
);
}
/* ---- phone menu ---- */
let open = false;
/* No links and no CTA means no menu button and no card: the scroll
swap and dropdowns above still run. */
const setMenu = (next: boolean, returnFocus = false) => {
if (!burger || !menu || open === next) return;
open = next;
root.toggleAttribute("data-sph-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
};
burger?.addEventListener("click", () => setMenu(!open), { signal });
menu?.addEventListener(
"click",
(e) => {
if ((e.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
root.addEventListener(
"focusout",
(e) => {
const next = e.relatedTarget as Node | null;
if (open && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
/* Escape and outside click on the document: after a client-side
navigation focus can sit outside the header. */
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
const openDrop = drops.find((d) => d.hasAttribute("data-open"));
if (openDrop) {
e.preventDefault();
setDrop(openDrop, false, openDrop.contains(document.activeElement));
} else if (open) {
e.preventDefault();
setMenu(false, true);
}
},
{ signal },
);
document.addEventListener(
"pointerdown",
(e) => {
if (root.contains(e.target as Node)) return;
closeDrops();
setMenu(false);
},
{ signal },
);
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDrops();
},
{ signal },
);
document.addEventListener(
"astro:before-swap",
() => {
setMenu(false);
closeDrops();
controller.abort();
delete root.dataset.sphReady;
},
{ once: true, signal },
);
}
const boot = () => document.querySelectorAll<HTMLElement>("[data-sph-root]").forEach(initScrollPillHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ScrollPillHeader.astro =====
===== FILE (supporting): src/components/ScrollPillHeader.md =====
# Scroll pill header
A 1350px pill bar fixed 20px from the top of the viewport. Over a dark hero
it is a faint white wash with white type; once the page scrolls past
`threshold` it turns solid white with navy type. Logo left, links centred
(an item with `children` opens a small dropdown card), an orange "Get a
Quote" pill right. Below 1000px it folds to the logo and a round menu button
that opens a white card under the bar.
Catalog page: `/components/scroll-pill-header`
Part of the Tideway family (see README → Page templates).
## Files required
- **`ScrollPillHeader.astro`** — the whole component. No imports, no packages.
- `ScrollPillHeader.md` — this file, not needed to run.
## Basic usage
```astro
---
import ScrollPillHeader from "../components/ScrollPillHeader.astro";
---
<ScrollPillHeader
currentPath={Astro.url.pathname}
brandName="Your Business"
items={[
{ label: "Home", href: "/", children: [{ label: "Homepage", href: "/" }, { label: "Landing", href: "/landing" }] },
{ label: "About", href: "/about" },
{ label: "Services", href: "/services" },
{ label: "Contact", href: "/contact" },
]}
cta={{ label: "Get a Quote", href: "/quote" }}
/>
```
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `{ label, href?, children?, groups? }[]` | `[]` | `children` turns an item into a dropdown trigger. `groups` (mega-menu data from other headers) is flattened into one dropdown. |
| `currentPath` | `string?` | — | Pass `Astro.url.pathname` for `aria-current`. |
| `brandName` | `string?` | — | Drawn wordmark text. No name and no `logo`: no brand link. |
| `brandHref` | `string?` | `"/"` | Logo link. |
| `logo` | `{ src, srcOnDark?, alt, width?, height? }?` | drawn mark | `srcOnDark` is shown while the bar is transparent. |
| `cta` | `{ label, href } \| null` | `null` | Orange pill. Omitted or `null`: no pill. |
| `solid` | `boolean?` | `false` | Always white; for pages without a dark hero. |
| `threshold` | `number?` | `8` | Scroll px before the bar turns solid. |
| `navLabel` / `menuLabel` | `string?` | `"Primary"` / `"Menu"` | Accessible names. |
| `fontStylesheet` | `string?` | Outfit + Noto Sans + Inter | `""` skips it. |
| `id` | `string?` | random | Seeds `aria-controls` ids. |
No business data is built in: brand, links and pill are all yours. Each
renders only when supplied, the menu button and card only when there are
links or a pill to put in them (the scroll swap runs either way), and with
none of the three the header renders nothing. The catalog preview passes the
demo "Tideway" brand, links and pill as props.
## Behaviour (measured on the reference)
- **Scroll swap:** background `rgba(255,255,255,.1)` → `#fff`, link colour
white → `#0b4654`, over 0.4s. The reference swaps within the first few
pixels of scroll.
- **Link hover:** colour → `#ff6d45`. The dropdown chevron rotates 180°.
- **Pill hover:** background `#ff6d45` → `#052931`; the white arrow disc
rotates −45°. 0.3s.
- **Dropdown:** a white card with uppercase links split by hairlines, 16px
under the trigger; fades up 6px.
- **Phone menu:** the round ⊕ button rotates 45° to an × while open.
- **Reduced motion:** transitions off; state changes stay.
## Accessibility
- Dropdown triggers are `<button aria-expanded aria-controls>`; hover opens
them only for a mouse on a hover-capable device.
- Escape closes the open dropdown (focus returns to its trigger) or the phone
menu (focus returns to the menu button). Outside click and focus leaving
close both.
- Without JavaScript the bar is solid, dropdowns open on hover/focus-within
and the phone links are shown as a plain list.
## Approximations
- The reference logo is a raster wordmark; this draws a generic arrow-in-arc
mark and a text wordmark. Pass `logo` for the real one.
- Phone sizes (bar 64px, 12px offsets) and the 1000px breakpoint are chosen,
not measured; the reference switches to its phone bar somewhere below 810px.
===== END src/components/ScrollPillHeader.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.