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 "Contact bar overlay 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: Contact bar overlay header
Library page: https://astro.baysixmedia.com/components/contact-bar-overlay-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: 164px tall at desktop and tablet (76px contact bar, then a 100px nav bar tucked 12px up into it) and 100px on a phone (24px gap plus a 76px bar). Pair it with BreadcrumbPageBanner, whose content starts 300px down (124px on a phone). On a page with no dark banner, reserve that height or pass overlay={false}.
- Any positioned ancestor (relative, absolute, transform, filter, contain) becomes the containing block and the header pins to its top.
- White text on a translucent navy bar (rgb(2 14 40 / .56), light 6px backdrop blur): it needs a photo or dark banner under it.
- Dropdowns open on mouse hover (fine pointers only), on click, Enter or Space; a click on a hover-opened panel pins it. Escape (bound to the document), outside click, tabbing out and crossing 1200px close them. Without JavaScript they open on hover and focus-within.
- Below 1200px: a menu button and a navy card under the bar (absolute, no body scroll lock) with dropdown groups as disclosures and the contact links repeated at the bottom. Escape returns focus to the button; closed card links are visibility:hidden. Without JavaScript the card is shown open.
- The contact bar shows from 810px; the address hides below 1100px and the "Follow Us" label below 960px so the bar never overflows. contacts={[]} and social={[]} drop the bar entirely.
- z-index 30. A banner with its own higher stacking context will cover it.
- Pass currentPath={Astro.url.pathname} for aria-current; it is not inferred. An item with `children` renders as a <button>; its `href` is ignored.
- Social icons are generic glyphs (facebook, x, instagram, linkedin, youtube, link) drawn inline; external links open in a new tab.
- Loads Outfit and DM Sans from Google Fonts by default. Pass fontStylesheet="" on a site that already loads them. Theme with the --tg-* custom properties on an ancestor.
--- 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/ContactBarOverlayHeader.astro =====
---
/**
* ContactBarOverlayHeader
*
* A two-tier header laid over a page banner: a 76px contact bar (phone,
* email and address links with orange icons, "Follow Us:" and four social
* circles), and under it a rounded, dark translucent nav bar inset from the
* edges — logo, the same links and dropdowns as SolidBarHeader in white, and
* an orange "Free Quote!" pill. Below 1200px the links fold into a menu
* button and a navy card; below 810px the contact bar is hidden.
*
* `overlay` (default true) positions the root absolutely at the top of its
* containing block, so the banner runs underneath it and it scrolls away
* with the page. `overlay={false}` puts it in the flow on a navy band.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks.
*/
export interface ContactBarOverlayHeaderLink {
label: string;
href: string;
}
export interface ContactBarOverlayHeaderItem {
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?: ContactBarOverlayHeaderLink[];
}
export interface ContactBarOverlayHeaderContact {
icon: "phone" | "email" | "address";
label: string;
href: string;
}
export type ContactBarOverlayHeaderNetwork = "facebook" | "x" | "instagram" | "linkedin" | "youtube" | "link";
export interface ContactBarOverlayHeaderSocial {
network: ContactBarOverlayHeaderNetwork;
href: string;
/** Accessible name, e.g. "Your Business on LinkedIn". */
label: string;
}
export interface ContactBarOverlayHeaderProps {
items?: ContactBarOverlayHeaderItem[];
/** 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 (use a light logo). */
logo?: { src: string; alt: string; width?: number; height?: number };
cta?: { label: string; href: string } | null;
/** The contact bar's links. `[]` with `social: []` drops the bar. */
contacts?: ContactBarOverlayHeaderContact[];
socialLabel?: string;
social?: ContactBarOverlayHeaderSocial[];
/** Absolute over the banner (true) or in the flow on a navy band (false). */
overlay?: boolean;
navLabel?: string;
contactLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
id?: string;
}
interface Props extends ContactBarOverlayHeaderProps {}
/* No invented business: links, brand, CTA, contact details and social
profiles are all the caller's. Each block renders only when supplied;
with none of them, nothing renders. */
const {
items = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
contacts = [],
socialLabel = "Follow Us :",
social = [],
overlay = true,
navLabel = "Primary",
contactLabel = "Contact details",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
id,
} = Astro.props;
const seed = id ? id : `cbo-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const hasTopBar = contacts.length > 0 || social.length > 0;
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
/* The phone menu carries the links, the CTA and the contact details. */
const hasMenu = hasItems || Boolean(cta) || contacts.length > 0;
const hasBar = hasBrand || hasMenu;
const hasContent = hasTopBar || hasBar;
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: ContactBarOverlayHeaderItem) =>
Boolean(item.children?.some((c) => isCurrent(c.href)));
const isExternal = (href: string) => /^https?:\/\//.test(href);
/* Contact-bar icons, drawn on a 16px grid. */
const CONTACT_ICONS: Record<ContactBarOverlayHeaderContact["icon"], string> = {
phone:
'<path d="M4.2 1.5 6.3 4a1 1 0 0 1-.1 1.3L5 6.5a8.4 8.4 0 0 0 4.5 4.5l1.2-1.2a1 1 0 0 1 1.3-.1l2.5 2.1a1 1 0 0 1 .1 1.4l-1 1.1c-.7.7-1.7.9-2.6.6A14 14 0 0 1 1.1 4.9c-.3-.9-.1-1.9.6-2.6l1-1a1 1 0 0 1 1.5.2z" fill="currentColor"/>',
email:
'<path d="M1 4.2A1.7 1.7 0 0 1 2.7 2.5h10.6A1.7 1.7 0 0 1 15 4.2v.3L8 9 1 4.5z" fill="currentColor"/><path d="M1 6.3l6.5 4.2a1 1 0 0 0 1 0L15 6.3v5.5a1.7 1.7 0 0 1-1.7 1.7H2.7A1.7 1.7 0 0 1 1 11.8z" fill="currentColor"/>',
address:
'<path d="M8 .8a5.6 5.6 0 0 0-5.6 5.6c0 4.1 5 8.5 5.2 8.7a.6.6 0 0 0 .8 0c.2-.2 5.2-4.6 5.2-8.7A5.6 5.6 0 0 0 8 .8zm0 7.9a2.3 2.3 0 1 1 0-4.6 2.3 2.3 0 0 1 0 4.6z" fill="currentColor"/>',
};
/* Generic social glyphs on a 24px grid. */
const GLYPHS: Record<ContactBarOverlayHeaderNetwork, string> = {
facebook:
'<path d="M13.6 21v-7.6h2.6l.4-3.1h-3V8.4c0-.9.3-1.5 1.5-1.5h1.6V4.1c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.3H7.9v3.1h2.6V21z" fill="currentColor"/>',
x: '<path d="M4 4h4.6l11.4 16h-4.6z" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="M19.4 4l-6.2 7M10.8 13l-6.2 7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/>',
instagram:
'<rect x="4" y="4" width="16" height="16" rx="4.6" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="12" cy="12" r="3.7" fill="none" stroke="currentColor" stroke-width="1.8"/><circle cx="16.6" cy="7.4" r="1.1" fill="currentColor"/>',
linkedin:
'<rect x="4.2" y="9.2" width="3.3" height="10.6" fill="currentColor"/><circle cx="5.85" cy="5.9" r="1.95" fill="currentColor"/><path d="M10 9.2h3.2v1.5c.5-.9 1.7-1.8 3.5-1.8 3.4 0 4 2.2 4 5.1v5.8h-3.3v-5.1c0-1.3 0-2.9-1.8-2.9s-2.1 1.4-2.1 2.8v5.2H10z" fill="currentColor"/>',
youtube:
'<rect x="3" y="6" width="18" height="12.5" rx="3.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.4 9.4v5.7l4.9-2.85z" fill="currentColor"/>',
link: '<path d="M10 14l4-4M8.5 11.5 6.6 13.4a3.2 3.2 0 0 0 4.5 4.5l1.9-1.9M15.5 12.5l1.9-1.9a3.2 3.2 0 0 0-4.5-4.5L11 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>',
};
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
class:list={["cbo", overlay ? "cbo--overlay" : "cbo--flow", !hasTopBar && "cbo--no-top"]}
id={id}
data-header-root
data-cbo-root
>
{
hasTopBar && (
<div class="cbo-top">
{contacts.length > 0 && (
<ul class="cbo-contacts" role="list" aria-label={contactLabel}>
{contacts.map((c) => (
<li class={`cbo-contact cbo-contact--${c.icon}`}>
<a
class="cbo-contact-link"
href={c.href}
target={isExternal(c.href) ? "_blank" : undefined}
rel={isExternal(c.href) ? "noopener noreferrer" : undefined}
>
<svg class="cbo-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
)}
{social.length > 0 && (
<div class="cbo-follow">
{socialLabel && <span class="cbo-follow-label" aria-hidden="true">{socialLabel}</span>}
<ul class="cbo-social" role="list">
{social.map((s) => (
<li>
<a
class="cbo-social-link"
href={s.href}
aria-label={s.label}
target={isExternal(s.href) ? "_blank" : undefined}
rel={isExternal(s.href) ? "noopener noreferrer" : undefined}
>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" set:html={GLYPHS[s.network] ?? GLYPHS.link} />
</a>
</li>
))}
</ul>
</div>
)}
</div>
)
}
{hasBar && (
<div class="cbo-barwrap">
<div class="cbo-bar">
{hasBrand && (
<a class="cbo-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="cbo-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 157}
height={logo.height ?? 30}
/>
) : (
<>
<svg class="cbo-mark" viewBox="0 0 46 30" aria-hidden="true" focusable="false">
<path d="M2 9h11M5 14h9M1 19h13" fill="none" stroke="var(--cbo-orange)" stroke-width="2.6" stroke-linecap="round" />
<rect x="14" y="4" width="17" height="15" rx="2" fill="currentColor" />
<path d="M32 8h6.2l5.3 5.6V19H32z" fill="currentColor" />
<path d="M34.4 10.4h3.2l3 3.2h-6.2z" fill="var(--cbo-orange)" />
<circle cx="20" cy="21.5" r="3.4" fill="currentColor" stroke="var(--cbo-navy)" stroke-width="1.6" />
<circle cx="37.5" cy="21.5" r="3.4" fill="currentColor" stroke="var(--cbo-navy)" stroke-width="1.6" />
<path d="M4 27.5h40" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="cbo-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="cbo-nav" aria-label={navLabel}>
<ul class="cbo-list" role="list">
{
items.map((item, i) => {
if (item.children && item.children.length > 0) {
const panelId = `${seed}-panel-${i}`;
return (
<li class="cbo-item cbo-item--dd" data-cbo-dd>
<button
type="button"
class="cbo-link cbo-trigger"
aria-expanded="false"
aria-controls={panelId}
data-cbo-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="cbo-plus" viewBox="0 0 11 11" aria-hidden="true" focusable="false">
<path d="M5.5 1v9M1 5.5h9" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<div class="cbo-panel" id={panelId} data-cbo-panel>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="cbo-panel-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</div>
</li>
);
}
return (
<li class="cbo-item">
<a
class="cbo-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{
cta && (
<a class="cbo-cta" href={cta.href}>
<span>{cta.label}</span>
<svg class="cbo-arrow" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
<path d="M2 10L10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)
}
{hasMenu && (
<button
type="button"
class="cbo-burger"
aria-expanded="false"
aria-controls={menuId}
data-cbo-burger
>
<span class="cbo-sr">{menuLabel}</span>
<span class="cbo-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
)}
</div>
{hasMenu && (
<nav class="cbo-menu" id={menuId} aria-label={navLabel} data-cbo-menu>
{hasItems && (
<ul class="cbo-menu-list" role="list">
{
items.map((item, i) =>
item.children && item.children.length > 0 ? (
<li class="cbo-menu-group" data-cbo-sub>
<button
type="button"
class="cbo-menu-link cbo-menu-toggle"
aria-expanded="true"
aria-controls={`${seed}-sub-${i}`}
data-cbo-subtoggle
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="cbo-plus" viewBox="0 0 11 11" aria-hidden="true" focusable="false">
<path d="M5.5 1v9M1 5.5h9" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
</button>
<ul class="cbo-sublist" id={`${seed}-sub-${i}`} role="list" data-cbo-sublist>
{item.children.map((child) => (
<li>
<a
class="cbo-menu-link cbo-menu-sublink"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li>
<a
class="cbo-menu-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
)}
{
cta && (
<a class="cbo-cta cbo-cta--menu" href={cta.href}>
<span>{cta.label}</span>
<svg class="cbo-arrow" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
<path d="M2 10L10 2M3.5 2H10v6.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</a>
)
}
{
contacts.length > 0 && (
<ul class="cbo-menu-contacts" role="list" aria-label={contactLabel}>
{contacts.map((c) => (
<li>
<a class="cbo-contact-link" href={c.href}>
<svg class="cbo-contact-icon" viewBox="0 0 16 16" aria-hidden="true" focusable="false" set:html={CONTACT_ICONS[c.icon]} />
<span>{c.label}</span>
</a>
</li>
))}
</ul>
)
}
</nav>
)}
</div>
)}
</header>
)}
<style>
.cbo {
--cbo-navy: var(--tg-navy, #020e28);
--cbo-orange: var(--tg-orange, #fd5523);
--cbo-muted: var(--tg-muted, #61697d);
--cbo-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--cbo-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--cbo-dd-hover: #ff6060;
--cbo-dur: 0.3s;
--cbo-ease: cubic-bezier(0.44, 0, 0.56, 1);
z-index: 30;
box-sizing: border-box;
width: 100%;
font-family: var(--cbo-body);
color: #fff;
}
.cbo *,
.cbo *::before,
.cbo *::after {
box-sizing: border-box;
}
.cbo ul {
margin: 0;
padding: 0;
list-style: none;
}
.cbo--overlay {
position: absolute;
inset: 0 0 auto 0;
background: transparent;
}
.cbo--flow {
position: relative;
background: var(--cbo-navy);
padding-bottom: 24px;
}
/* ---- contact bar ---- */
.cbo-top {
display: none;
}
.cbo .cbo-contacts {
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.cbo-contact-link {
display: inline-flex;
align-items: center;
gap: 15px;
min-height: 44px;
color: #fff;
font: 400 16px/21px var(--cbo-body);
text-decoration: none;
white-space: nowrap;
border-radius: 4px;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-contact-link:hover {
color: var(--cbo-orange);
}
.cbo-contact-icon {
width: 16px;
height: 16px;
flex: none;
color: var(--cbo-orange);
}
.cbo-follow {
display: flex;
align-items: center;
gap: 16px;
flex: none;
}
.cbo-follow-label {
font: 700 16px/19px var(--cbo-display);
white-space: nowrap;
}
.cbo .cbo-social {
display: flex;
align-items: center;
gap: 16px;
}
.cbo-social-link {
position: relative;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 100px;
background: rgb(255 255 255 / 0.15);
color: #fff;
transition: background-color var(--cbo-dur) var(--cbo-ease);
}
/* 36px circle as measured, 44px target. */
.cbo-social-link::after {
content: "";
position: absolute;
inset: -4px;
border-radius: inherit;
}
.cbo-social-link svg {
width: 15px;
height: 15px;
}
.cbo-social-link:hover {
background: var(--cbo-orange);
}
/* ---- the glass nav bar ---- */
.cbo-barwrap {
position: relative;
padding: 24px 20px 0;
}
.cbo-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 76px;
padding: 0 20px 0 24px;
border-radius: 1000px;
/* rgba(2,14,40,.8) at opacity .7, as measured, flattened into one fill
so the bar's contents stay fully opaque. */
background: rgb(2 14 40 / 0.56);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
}
.cbo-brand {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
color: #fff;
text-decoration: none;
border-radius: 6px;
flex: none;
}
.cbo-mark {
width: 46px;
height: 30px;
flex: none;
}
.cbo-wordmark {
font: 600 24px/1 var(--cbo-display);
letter-spacing: -0.01em;
}
.cbo-logo-img {
display: block;
height: 30px;
width: auto;
}
.cbo-nav {
display: none;
}
.cbo .cbo-list {
display: flex;
align-items: center;
gap: clamp(28px, calc(5vw - 30px), 42px);
padding: 9px 20px;
}
.cbo-item {
position: relative;
}
.cbo-link {
display: inline-flex;
align-items: center;
gap: 1px;
padding: 9px 6px;
margin: -9px -6px;
border: 0;
border-radius: 6px;
background: none;
color: #fff;
font: 500 16px/26px var(--cbo-display);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-link:hover,
.cbo-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-trigger:hover,
.cbo-trigger[aria-expanded="true"],
.cbo-trigger[data-current] {
color: var(--cbo-dd-hover);
}
.cbo-plus {
width: 11px;
height: 11px;
flex: none;
transition: transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-trigger:hover .cbo-plus,
.cbo-trigger[aria-expanded="true"] .cbo-plus {
transform: rotate(180deg);
}
.cbo-panel {
position: absolute;
top: calc(100% + 34px);
left: -22px;
min-width: 220px;
padding: 14px 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 44px rgb(2 14 40 / 0.22);
border-top: 3px solid var(--cbo-orange);
opacity: 0;
transform: translateY(10px);
visibility: hidden;
pointer-events: none;
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s linear var(--cbo-dur);
z-index: 2;
}
.cbo-panel::before {
content: "";
position: absolute;
inset: -37px 0 100% 0;
}
.cbo:not([data-cbo-ready]) .cbo-item--dd:hover .cbo-panel,
.cbo:not([data-cbo-ready]) .cbo-item--dd:focus-within .cbo-panel,
.cbo[data-cbo-ready] .cbo-item--dd[data-open] .cbo-panel {
opacity: 1;
transform: none;
visibility: visible;
pointer-events: auto;
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s;
}
.cbo-panel-link {
display: flex;
align-items: center;
min-height: 40px;
color: var(--cbo-navy);
font: 500 16px/1.3 var(--cbo-display);
text-decoration: none;
white-space: nowrap;
border-radius: 4px;
transition:
color var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-panel-link:hover,
.cbo-panel-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-panel-link:hover {
transform: translateX(4px);
}
/* ---- Free Quote pill ---- */
.cbo-cta {
display: none;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 30px;
border-radius: 100px;
background: var(--cbo-orange);
color: #fff;
font: 600 14px/1 var(--cbo-body);
text-decoration: none;
white-space: nowrap;
flex: none;
transition: background-color var(--cbo-dur) var(--cbo-ease);
}
.cbo-arrow {
width: 12px;
height: 12px;
flex: none;
transition: transform var(--cbo-dur) var(--cbo-ease);
}
.cbo-cta:hover {
background: var(--cbo-muted);
}
.cbo-cta:hover .cbo-arrow {
transform: rotate(45deg);
}
/* ---- burger ---- */
.cbo-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: #fff;
cursor: pointer;
}
.cbo:not([data-cbo-ready]) .cbo-burger {
display: none;
}
.cbo-burger-lines {
position: relative;
display: block;
width: 24px;
height: 18px;
}
.cbo-burger-lines span {
position: absolute;
left: 0;
width: 24px;
height: 2px;
border-radius: 1px;
background: currentColor;
transition:
transform var(--cbo-dur) var(--cbo-ease),
opacity var(--cbo-dur) var(--cbo-ease);
}
.cbo-burger-lines span:nth-child(1) { top: 0; }
.cbo-burger-lines span:nth-child(2) { top: 8px; }
.cbo-burger-lines span:nth-child(3) { top: 16px; }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(2) { opacity: 0; }
.cbo[data-cbo-open] .cbo-burger-lines span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
/* ---- small-screen menu card ---- */
.cbo-menu {
margin: 12px 0 0;
padding: 12px 22px 22px;
border-radius: 20px;
background: var(--cbo-navy);
border: 1px solid rgb(255 255 255 / 0.12);
box-shadow: 0 22px 50px rgb(0 0 0 / 0.35);
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s linear var(--cbo-dur);
}
.cbo[data-cbo-ready] .cbo-menu {
position: absolute;
top: calc(100% + 10px);
left: 20px;
right: 20px;
margin: 0;
max-height: calc(100vh - 140px);
max-height: calc(100dvh - 140px);
overflow-y: auto;
}
.cbo[data-cbo-ready]:not([data-cbo-open]) .cbo-menu {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
pointer-events: none;
}
.cbo[data-cbo-ready][data-cbo-open] .cbo-menu {
transition:
opacity var(--cbo-dur) var(--cbo-ease),
transform var(--cbo-dur) var(--cbo-ease),
visibility 0s;
}
.cbo-menu-list > li + li {
border-top: 1px solid rgb(255 255 255 / 0.1);
}
.cbo-menu-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 48px;
padding: 0;
border: 0;
background: none;
color: #fff;
font: 500 17px/1.2 var(--cbo-display);
text-align: left;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
transition: color var(--cbo-dur) var(--cbo-ease);
}
.cbo-menu-link:hover,
.cbo-menu-link[aria-current="page"] {
color: var(--cbo-orange);
}
.cbo-menu-toggle:hover,
.cbo-menu-toggle[data-current] {
color: var(--cbo-dd-hover);
}
.cbo-menu-toggle .cbo-plus {
width: 13px;
height: 13px;
}
.cbo-menu-toggle[aria-expanded="true"] .cbo-plus {
transform: rotate(45deg);
}
.cbo .cbo-sublist {
padding: 0 0 10px 14px;
margin-left: 2px;
border-left: 2px solid rgb(255 255 255 / 0.14);
}
.cbo-sublist[hidden] {
display: none;
}
.cbo-menu-sublink {
min-height: 44px;
color: #b0b2b8;
font: 400 16px/1.3 var(--cbo-body);
}
.cbo-cta--menu {
display: flex;
justify-content: center;
margin-top: 14px;
}
.cbo .cbo-menu-contacts {
margin-top: 14px;
padding-top: 8px;
border-top: 1px solid rgb(255 255 255 / 0.1);
}
.cbo-menu-contacts .cbo-contact-link {
gap: 12px;
font-size: 15px;
white-space: normal;
}
/* ---- focus ---- */
.cbo a:focus-visible,
.cbo button:focus-visible {
outline: 2px solid var(--cbo-orange);
outline-offset: 3px;
}
.cbo-cta:focus-visible {
outline-color: #fff;
}
.cbo-panel a:focus-visible {
outline-color: var(--cbo-orange);
}
.cbo-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- tablet and up: the contact bar appears ---- */
@media (min-width: 810px) {
.cbo-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
min-height: 76px;
padding: 19px 20px 21px;
}
/* The nav bar tucks 12px up into the contact bar, as measured
(bar at 64px under a 76px contact bar). */
.cbo-barwrap {
padding: 0 24px;
margin-top: -12px;
}
.cbo--no-top .cbo-barwrap {
margin-top: 0;
padding-top: 24px;
}
.cbo-bar {
min-height: 100px;
padding: 22px 24px;
}
.cbo[data-cbo-ready] .cbo-menu {
left: 24px;
right: 24px;
max-width: 480px;
left: auto;
}
}
@media (min-width: 810px) and (max-width: 1099.98px) {
.cbo-contact--address {
display: none;
}
}
@media (min-width: 810px) and (max-width: 959.98px) {
.cbo-follow-label {
display: none;
}
}
/* ---- desktop: links and pill appear, the menu goes ---- */
@media (min-width: 1200px) {
.cbo-brand {
padding-right: clamp(24px, calc(20vw - 208px), 80px);
box-sizing: content-box;
}
.cbo-nav {
display: block;
flex: 1 1 auto;
min-width: 0;
}
.cbo-cta {
display: inline-flex;
}
.cbo-burger,
.cbo .cbo-menu {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cbo {
--cbo-dur: 1ms;
}
.cbo-trigger:hover .cbo-plus,
.cbo-trigger[aria-expanded="true"] .cbo-plus,
.cbo-cta:hover .cbo-arrow,
.cbo-panel-link:hover {
transform: none;
}
}
@media (forced-colors: active) {
.cbo-bar,
.cbo-panel,
.cbo-menu,
.cbo-cta,
.cbo-social-link {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* ContactBarOverlayHeader controller. Same behaviour as SolidBarHeader,
* deliberately duplicated so each header is one self-contained file.
*/
function initContactBarOverlayHeader(root: HTMLElement) {
if (root.dataset.cboReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-cbo-burger]");
const menu = root.querySelector<HTMLElement>("[data-cbo-menu]");
const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-cbo-dd]")];
const subs = [...root.querySelectorAll<HTMLElement>("[data-cbo-sub]")];
root.dataset.cboReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 1200px)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
let openDd: HTMLElement | null = null;
let openedByHover = false;
let hoverTimer = 0;
const triggerOf = (dd: HTMLElement) =>
dd.querySelector<HTMLButtonElement>("[data-cbo-trigger]");
function closeDropdown(returnFocus = false) {
window.clearTimeout(hoverTimer);
if (!openDd) return;
const dd = openDd;
openDd = null;
openedByHover = false;
dd.removeAttribute("data-open");
const trigger = triggerOf(dd);
trigger?.setAttribute("aria-expanded", "false");
if (returnFocus) trigger?.focus({ preventScroll: true });
}
function openDropdown(dd: HTMLElement, byHover = false) {
window.clearTimeout(hoverTimer);
if (openDd === dd) return;
closeDropdown();
openDd = dd;
openedByHover = byHover;
dd.setAttribute("data-open", "");
triggerOf(dd)?.setAttribute("aria-expanded", "true");
}
dropdowns.forEach((dd) => {
const trigger = triggerOf(dd);
trigger?.addEventListener(
"click",
() => {
if (openDd === dd && openedByHover) {
openedByHover = false;
return;
}
if (openDd === dd) closeDropdown();
else openDropdown(dd);
},
{ signal },
);
dd.addEventListener(
"pointerenter",
(event) => {
if (event.pointerType !== "mouse" || !finePointer.matches) return;
if (openDd && openDd !== dd && !openedByHover) return;
openDropdown(dd, true);
},
{ signal },
);
dd.addEventListener(
"pointerleave",
(event) => {
if (event.pointerType !== "mouse" || openDd !== dd || !openedByHover) return;
window.clearTimeout(hoverTimer);
hoverTimer = window.setTimeout(() => {
if (openDd === dd && openedByHover && !dd.contains(document.activeElement)) closeDropdown();
}, 160);
},
{ signal },
);
dd.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (openDd === dd && next && !dd.contains(next)) closeDropdown();
},
{ signal },
);
});
let menuOpen = false;
function setMenu(next: boolean, returnFocus = false) {
if (!burger || !menu || menuOpen === next) return;
menuOpen = next;
root.toggleAttribute("data-cbo-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
}
subs.forEach((sub) => {
const toggle = sub.querySelector<HTMLButtonElement>("[data-cbo-subtoggle]");
const list = sub.querySelector<HTMLElement>("[data-cbo-sublist]");
if (!toggle || !list) return;
const set = (open: boolean) => {
toggle.setAttribute("aria-expanded", String(open));
list.hidden = !open;
};
set(false);
toggle.addEventListener(
"click",
() => set(toggle.getAttribute("aria-expanded") !== "true"),
{ signal },
);
signal.addEventListener("abort", () => set(true));
});
burger?.addEventListener("click", () => setMenu(!menuOpen), { signal });
menu?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
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 },
);
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (openDd && !openDd.contains(target)) closeDropdown();
if (menuOpen && !root.contains(target)) setMenu(false);
},
{ signal },
);
menu?.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (menuOpen && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDropdown();
},
{ signal },
);
function destroy() {
closeDropdown();
setMenu(false);
controller.abort();
delete root.dataset.cboReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-cbo-root]")
.forEach(initContactBarOverlayHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ContactBarOverlayHeader.astro =====
===== FILE (supporting): src/components/ContactBarOverlayHeader.md =====
# Contact bar overlay header
A two-tier header for inner pages, laid over a photo banner: a 76px contact
bar (phone, email and address links with orange icons, "Follow Us:" and four
social circles) and, tucked 12px into it, a rounded dark translucent nav bar
inset from the edges: logo, white links with four dropdowns and an orange
"Free Quote!" pill. Below 1200px the links fold into a menu button and a navy
card; below 810px the contact bar is hidden.
Catalog page: `/components/contact-bar-overlay-header`
Same items and same dropdown and menu behaviour as `SolidBarHeader` (the home
page header), duplicated so each is one self-contained file.
## Files required
**`ContactBarOverlayHeader.astro`** — the whole component. No imports, no
packages, no images. `ContactBarOverlayHeader.md` — this file, optional.
## Basic usage
```astro
---
import ContactBarOverlayHeader from "../components/ContactBarOverlayHeader.astro";
import BreadcrumbPageBanner from "../components/BreadcrumbPageBanner.astro";
---
<div style="position: relative">
<ContactBarOverlayHeader
currentPath={Astro.url.pathname}
brandName="Your Business"
items={[
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
]}
cta={{ label: "Get a quote", href: "/contact" }}
contacts={[
{ icon: "phone", label: "Phone: (555) 010-0000", href: "tel:+15550100000" },
{ icon: "email", label: "Email: hello@yourbusiness.com", href: "mailto:hello@yourbusiness.com" },
]}
social={[
{ network: "instagram", href: "https://instagram.com/yourbusiness", label: "Your Business on Instagram" },
]}
/>
<BreadcrumbPageBanner title="About Us" />
</div>
```
It is `position: absolute` at the top of its containing block: 164px tall at
810px and up, 100px on a phone. The banner runs underneath and leaves that
space clear. On a page with no dark banner, reserve the height or pass
`overlay={false}`, which puts it in the flow on a navy band.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `ContactBarOverlayHeaderItem[]?` | `[]` | `{ label, href?, children? }`; `children` makes a dropdown button. |
| `currentPath` | `string?` | — | Pass `Astro.url.pathname` for `aria-current`. |
| `brandName`, `brandHref`, `logo` | | —, `"/"`, — | As in SolidBarHeader. Use a light logo. No name and no `logo`: no brand link. |
| `cta` | `{ label, href } \| null` | `null` | Omitted or `null`: no pill. |
| `contacts` | `{ icon: "phone" \| "email" \| "address", label, href }[]?` | `[]` | The contact bar's links, repeated at the bottom of the small-screen card. Each `href` is used as given, so build `tel:`/`mailto:` links from your real number and address. |
| `socialLabel` | `string?` | `"Follow Us :"` | Shown only beside supplied `social` links. `""` drops the label. |
| `social` | `{ network, href, label }[]?` | `[]` | `network`: facebook, x, instagram, linkedin, youtube, link. `label` is the accessible name. Link to the business's own profile pages, never a platform home page. |
| `overlay` | `boolean?` | `true` | Absolute over the banner, or in the flow on navy. |
| `navLabel` | `string?` | `"Primary"` | |
| `contactLabel` | `string?` | `"Contact details"` | Accessible name of the contact list. |
| `menuLabel` | `string?` | `"Menu"` | |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `id` | `string?` | — | Root id; seeds the `aria-controls` ids. |
`contacts={[]}` and `social={[]}` (the defaults) together drop the contact
bar.
No business data is built in: brand, links, CTA, contact details and social
profiles are all yours. Each renders only when supplied, the menu button and
card only when there are links, a CTA or contact details to put in it, and
with nothing at all the header renders nothing. The catalog preview passes
the demo "Northpass" brand, link tree, CTA, contacts and social links as
props.
## Behaviour
| Element | Rest | Hover / open |
| :--- | :--- | :--- |
| Contact-bar links | white, DM Sans 16/21, orange 16px icons | text `#fd5523` |
| Social circles | 36px, `rgba(255,255,255,.15)` | `#fd5523` |
| Plain nav link | white, Outfit 500 16/26 | `#fd5523` |
| Dropdown trigger | white with a "+" | `#ff6060`, "+" rotates 180° |
| Free Quote pill | `#fd5523` | `#61697d`, the ↗ arrow rotates 45° |
The nav bar is 100px tall, 24px from each edge, radius 1000px, filled
`rgba(2,14,40,.8)` at opacity .7, flattened here to `rgb(2 14 40 / .56)` so
the bar's own contents stay opaque. Dropdowns, the small-screen card, Escape,
outside click, focus return, closed links out of the tab order and the reset
at 1200px work exactly as in SolidBarHeader. The card here is navy.
**Contact bar widths.** Shown from 810px. The address hides below 1100px and
the "Follow Us" label below 960px so nothing overflows.
**No JavaScript.** Dropdowns open on hover and `focus-within`; the card is
shown open under the bar. **Reduced motion.** 1ms durations, no transforms.
## Accessibility
- `<header>`; the contact bar is a labelled `<ul>`; social links carry
`aria-label`s (the "Follow Us" text is `aria-hidden` beside them).
- 36px social circles keep a 44px hit area through an inset pseudo-element.
- Buttons with `aria-expanded`/`aria-controls` in sync with visual state;
visible orange focus rings; external links open in a new tab with
`rel="noopener noreferrer"`.
- House lifecycle: idempotent `data-cbo-ready`, an `AbortController` aborted
on `astro:before-swap`, Escape bound to the document. Never scrolls or
moves focus on load.
## Images and credits
None. The truck mark, contact icons and social glyphs are original inline SVG.
## Approximations
- **Dropdown panels were not captured open**; their design and links are
invented, as in SolidBarHeader.
- **A 6px backdrop blur** is added to the bar; the dump shows no
`backdrop-filter`. Remove it in your copy for the flat measured fill.
- **Phone:** the reference hides the contact bar and shows a 76px bar 24px
down with logo and menu button, reproduced. The navy menu card and the
repeated contact links in it are this component's own.
- **Tablet (810–1199px)** was not captured; the contact bar plus a 100px bar
with a menu button is a reading of the desktop layout.
===== END src/components/ContactBarOverlayHeader.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.