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 "Call bar 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: Call bar header
Library page: https://astro.baysixmedia.com/components/call-bar-header/
Navigation mode: static — a flat list of links, with no panels to open
Position mode: normal
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:
- In the normal flow, 72px tall below 960px and 84px from 960px, with a 3px accent rule under it. Put it first in <body> after the skip link; nothing needs reserving and same-page anchors never land beneath it.
- The number: pass the verified number as phone.number; the tel: link is derived from it. With no phone the call action is simply absent — never a placeholder.
- Below 960px the call link is a 44px button that stays in the bar; the pages and the CTA move into a panel that opens under the bar, in the flow (it pushes the page down). Escape closes it and returns focus to the button; choosing a link closes it.
- From 960px the bar fits its own content: pages inline with the number written out, then pages inline with the call button, then the pages in the panel with the call button and the action still in the bar. A label never wraps and never runs under the actions; long page names or a long business name move the pages into the panel instead. Nothing to configure.
- Without JavaScript the panel is shown open whenever the pages are not inline and the menu button is hidden, so every page stays reachable.
- brandHref is optional: omitted, the brand is plain text rather than a link to an assumed route.
- Loads Archivo from Google Fonts by default. Pass fontStylesheet="" on a site that already loads it. Theme with --cbh-surface, --cbh-ink, --cbh-muted, --cbh-accent and --cbh-accent-ink on an ancestor.
--- 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 sits in the normal flow and scrolls away with the page. Nothing needs reserving for it.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
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 normal 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/CallBarHeader.astro =====
---
/**
* CallBarHeader
*
* A dark, in-flow site header for a business people phone: the business
* name (or its logo) on the left, the site's pages in the middle, and on the
* right the verified number as a real `tel:` link beside one primary action.
* Below 960px — and on a desktop bar too narrow for its own content — the
* number collapses to a 44px call button that stays in the bar (the one action
* a phone visitor wants is never behind the menu), and when the pages do not
* fit on one line they move into a panel that opens under the bar, in the
* flow, pushing the page down rather than covering it. A page label never
* wraps and never runs under the actions.
*
* In the normal flow: nothing to reserve, nothing to offset, no anchor
* lands under it. Every block renders only when its data is supplied; with
* nothing supplied, nothing renders. No default name, number, route or CTA.
*
* Reads --cbh-* custom properties from any ancestor, with fallbacks.
* Step 34: the faces come through --cbh-family / --cbh-display (Archivo by
* default) and the bar through --cbh-grid-max / --cbh-grid-gutter (desktop),
* so a site can put the brand on its page grid and set its own type — with
* no input the header is exactly as it was.
*/
export interface CallBarHeaderLink {
label: string;
href: string;
}
export interface CallBarHeaderProps {
/** The business's name. Shown as the wordmark when there is no logo; the logo's accessible name otherwise falls back to it. */
brandName?: string;
/** Where the brand links. Omitted: the brand is not a link (no route is assumed). */
brandHref?: string;
/** The business's own logo in place of the wordmark. */
logo?: { src: string; alt: string; width?: number; height?: number };
/** The site's pages. */
items?: CallBarHeaderLink[];
/** Pass Astro.url.pathname: the matching link gets aria-current="page". */
currentPath?: string;
/** The verified number. Omitted or null: no call action at all. */
phone?: { number: string; label?: string } | null;
/** The one primary action. Omitted or null: none. */
cta?: { label: string; href: string } | null;
/** Accessible name of the <nav>. */
navLabel?: string;
/** Accessible name of the menu button. */
menuLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
interface Props extends CallBarHeaderProps {}
const {
brandName,
brandHref,
logo,
items = [],
currentPath,
phone = null,
cta = null,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Archivo:wght@500;600;700;800&display=swap",
class: className,
id,
} = Astro.props;
const SAFE = /^(?:https?:|mailto:|tel:|[#/.?])/i;
const safe = (href?: string) => (href && SAFE.test(href.trim()) ? href.trim() : undefined);
const brand = brandName?.trim() ?? "";
const brandLink = safe(brandHref);
const links = items.filter((i) => i?.label?.trim() && safe(i.href));
const number = phone?.number?.trim() ?? "";
const tel = number ? `tel:${number.replace(/[^\d+]/g, "")}` : "";
const callLabel = phone?.label?.trim() || "Call";
const action = cta && cta.label?.trim() && safe(cta.href) ? { label: cta.label.trim(), href: safe(cta.href)! } : null;
const hasBrand = Boolean(logo?.src || brand);
const hasMenu = links.length > 0 || Boolean(action);
const hasContent = hasBrand || hasMenu || Boolean(tel);
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href: string) => Boolean(currentPath && norm(href) === norm(currentPath));
/* Ids for aria-controls: seeded from `id`, so a second instance never collides. */
const seed = id ? id : `cbh-${Math.random().toString(36).slice(2, 8)}`;
const panelId = `${seed}-panel`;
/*
* Desktop fit (960px and up). A label never wraps and the pages never run under the actions: the bar
* takes the first of three states its content fits in —
* full pages inline, the number written out beside the action;
* compact pages inline, the number as the 44px call button it already is on phones;
* menu pages in the panel under the bar; the call button and the action stay in the bar.
* The widths below estimate what each state needs, per character at the bar's own sizes (calibrated on
* the wider common sans faces), and become container-query thresholds on the bar's frame — whose content
* box already excludes the site gutter and is capped by the site grid — for the first paint and for no
* JavaScript. The script then measures the real bar in its real face and corrects the state.
*/
const FIT = { brandChar: 11.4, linkChar: 7.9, linkPad: 24, linkGap: 4, ctaChar: 7.4, ctaPad: 40, callFixed: 60, callChar: 9.5, callIcon: 44, gap: 16, actionsGap: 8 };
const brandWidth = logo?.src ? (logo.width ?? 160) * Math.min(1, 40 / (logo.height ?? 36)) : brand.length * FIT.brandChar;
const navWidth = links.reduce((sum, l) => sum + l.label.trim().length * FIT.linkChar + FIT.linkPad, 0) + Math.max(0, links.length - 1) * FIT.linkGap;
const ctaWidth = action ? action.label.length * FIT.ctaChar + FIT.ctaPad : 0;
const needs = (callWidth: number) =>
Math.ceil((hasBrand ? brandWidth + FIT.gap : 0) + (links.length ? navWidth + FIT.gap : 0) + callWidth + (tel && action ? FIT.actionsGap : 0) + ctaWidth);
const fullAt = needs(tel ? FIT.callFixed + number.length * FIT.callChar : 0);
const inlineAt = needs(tel ? FIT.callIcon : 0);
const uid = seed.replace(/[^\w-]/g, "") || "cbh";
/* The same declarations as the [data-fit] rules in the stylesheet below, keyed to this instance. */
const INLINE_VARS = "--cbh-nav-d: block; --cbh-toggle-d: none; --cbh-panel-d: none; --cbh-brand-flex: none; --cbh-brand-ws: nowrap;";
const FULL_VARS = "--cbh-call-w: auto; --cbh-call-h: 48px; --cbh-call-pad: 4px 18px 4px 12px; --cbh-call-r: 12px; --cbh-call-text: flex;";
const scope = `.cbh[data-cbh-uid="${uid}"]:not([data-fit]) > .cbh-frame > *`;
const fitCss = `@media (min-width: 960px) { @container cbh (min-width: ${inlineAt}px) { ${scope} { ${INLINE_VARS} } }${tel ? ` @container cbh (min-width: ${fullAt}px) { ${scope} { ${FULL_VARS} } }` : ""} }`;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{
hasContent && (
<>
<style is:inline set:html={fitCss} />
<header class:list={["cbh", className]} id={id} data-cbh-root data-cbh-uid={uid}>
<div class="cbh-frame">
<div class="cbh-bar">
{hasBrand &&
(brandLink ? (
<a class="cbh-brand" href={brandLink}>
{logo?.src ? (
<img class="cbh-logo" src={logo.src} alt={logo.alt || brand} width={logo.width ?? 160} height={logo.height ?? 36} />
) : (
<span class="cbh-wordmark">{brand}</span>
)}
</a>
) : (
<span class="cbh-brand">
{logo?.src ? (
<img class="cbh-logo" src={logo.src} alt={logo.alt || brand} width={logo.width ?? 160} height={logo.height ?? 36} />
) : (
<span class="cbh-wordmark">{brand}</span>
)}
</span>
))}
{links.length > 0 && (
<nav class="cbh-nav" aria-label={navLabel}>
<ul role="list">
{links.map((link) => (
<li>
<a href={safe(link.href)} aria-current={isCurrent(link.href) ? "page" : undefined}>
{link.label}
</a>
</li>
))}
</ul>
</nav>
)}
<div class="cbh-actions">
{tel && (
<a class="cbh-call" href={tel} aria-label={`${callLabel} ${number}`}>
<svg class="cbh-call-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M6.6 3.5h2.6l1.4 4.1-1.9 1.3a11.5 11.5 0 0 0 6.4 6.4l1.3-1.9 4.1 1.4v2.6a2 2 0 0 1-2.2 2A16.8 16.8 0 0 1 4.6 5.7a2 2 0 0 1 2-2.2Z" />
</svg>
<span class="cbh-call-text">
<span class="cbh-call-label">{callLabel}</span>
<span class="cbh-call-number">{number}</span>
</span>
</a>
)}
{action && (
<a class="cbh-cta" href={action.href}>
{action.label}
</a>
)}
{hasMenu && (
<button type="button" class="cbh-toggle" aria-expanded="false" aria-controls={panelId} data-cbh-toggle>
<span class="cbh-sr">{menuLabel}</span>
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="cbh-bars" d="M4 7h16M4 12h16M4 17h16" />
<path class="cbh-cross" d="M6 6l12 12M18 6 6 18" />
</svg>
</button>
)}
</div>
</div>
{hasMenu && (
<div class="cbh-panel" id={panelId} data-cbh-panel>
{links.length > 0 && (
<ul role="list" class="cbh-panel-links">
{links.map((link) => (
<li>
<a href={safe(link.href)} aria-current={isCurrent(link.href) ? "page" : undefined}>
{link.label}
</a>
</li>
))}
</ul>
)}
{(tel || action) && (
<div class="cbh-panel-actions">
{tel && (
<a class="cbh-panel-call" href={tel}>
{callLabel} {number}
</a>
)}
{action && (
<a class="cbh-cta" href={action.href}>
{action.label}
</a>
)}
</div>
)}
</div>
)}
</div>
</header>
</>
)
}
<style>
.cbh {
--cbh-bg: var(--cbh-surface, #13232e);
--cbh-ink-color: var(--cbh-ink, #f4f1ea);
--cbh-muted-color: var(--cbh-muted, rgb(244 241 234 / 0.72));
--cbh-accent-color: var(--cbh-accent, #f2b441);
--cbh-accent-ink-color: var(--cbh-accent-ink, #1a1406);
--cbh-line: rgb(244 241 234 / 0.14);
--cbh-pad: 20px;
--cbh-h: 72px;
position: relative;
z-index: 20;
background: var(--cbh-bg);
color: var(--cbh-ink-color);
font-family: var(--cbh-family, "Archivo", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
border-bottom: 3px solid var(--cbh-accent-color);
}
/*
* The frame holds the site grid and gutter; its content box is the width the bar really has, so the
* fit thresholds (container queries on it) need no knowledge of either.
*/
.cbh-frame {
container: cbh / inline-size;
max-width: var(--cbh-grid-max, 1280px);
margin-inline: auto;
padding-inline: var(--cbh-pad);
}
.cbh-bar {
display: flex;
align-items: center;
gap: 16px;
min-height: var(--cbh-h);
}
/*
* Fit states (see the frontmatter). The variables default to the menu state; the pages go inline and
* the number is written out only when the state says the bar has room. Set on the frame's children.
*/
@media (min-width: 960px) {
.cbh[data-fit="full"] > .cbh-frame > *,
.cbh[data-fit="compact"] > .cbh-frame > * {
--cbh-nav-d: block;
--cbh-toggle-d: none;
--cbh-panel-d: none;
--cbh-brand-flex: none;
--cbh-brand-ws: nowrap;
}
.cbh[data-fit="full"] > .cbh-frame > * {
--cbh-call-w: auto;
--cbh-call-h: 48px;
--cbh-call-pad: 4px 18px 4px 12px;
--cbh-call-r: 12px;
--cbh-call-text: flex;
}
}
/* The brand shrinks and wraps before anything else moves: long legitimate names stay inside the bar. */
.cbh-brand {
display: inline-flex;
align-items: center;
flex: var(--cbh-brand-flex, 0 1 auto);
min-width: 0;
min-height: 44px;
color: inherit;
text-decoration: none;
}
.cbh-wordmark {
font-family: var(--cbh-display, "Archivo", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
font-weight: 800;
font-size: 1.125rem;
line-height: 1.15;
letter-spacing: -0.01em;
text-transform: uppercase;
overflow-wrap: anywhere;
white-space: var(--cbh-brand-ws, normal);
}
.cbh-logo {
display: block;
max-height: 40px;
width: auto;
max-width: 100%;
}
/* Never shrinks: a page list that does not fit changes the state, it is never squeezed into wrapped labels. */
.cbh-nav {
display: var(--cbh-nav-d, none);
flex: none;
}
.cbh-nav ul,
.cbh-panel-links {
margin: 0;
padding: 0;
list-style: none;
}
.cbh-actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
margin-inline-start: auto;
}
/* A 44px icon button that stays in the bar; written out with the number in the full desktop state. */
.cbh-call {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
width: var(--cbh-call-w, 44px);
min-height: var(--cbh-call-h, 44px);
padding: var(--cbh-call-pad, 0);
border-radius: var(--cbh-call-r, 999px);
background: var(--cbh-accent-color);
color: var(--cbh-accent-ink-color);
text-decoration: none;
}
.cbh-call-icon {
width: 20px;
height: 20px;
fill: currentColor;
flex: none;
}
.cbh-call-text {
display: var(--cbh-call-text, none);
}
.cbh-cta {
display: none;
align-items: center;
min-height: 44px;
padding: 0 18px;
border: 2px solid var(--cbh-ink-color);
border-radius: 999px;
color: var(--cbh-ink-color);
font-weight: 700;
font-size: 0.9375rem;
text-decoration: none;
white-space: nowrap;
}
.cbh-toggle {
display: var(--cbh-toggle-d, inline-flex);
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
border: 1px solid var(--cbh-line);
border-radius: 10px;
background: transparent;
color: inherit;
cursor: pointer;
}
.cbh-toggle svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.cbh-cross,
.cbh-toggle[aria-expanded="true"] .cbh-bars {
display: none;
}
.cbh-toggle[aria-expanded="true"] .cbh-cross {
display: inline;
}
/*
* The panel sits in the flow under the bar: it pushes the page down, never covers it. It lives in the
* frame, so it reaches back over the gutter to keep its rule edge to edge.
*/
.cbh-panel {
display: none;
border-top: 1px solid var(--cbh-line);
margin-inline: calc(-1 * var(--cbh-pad));
padding: 8px var(--cbh-pad) 20px;
}
.cbh-panel[data-open] {
display: var(--cbh-panel-d, block);
}
/* Without JavaScript the toggle cannot work, so the pages stay reachable in the open panel — unless they are inline. */
.cbh:not([data-cbh-ready]) .cbh-panel {
display: var(--cbh-panel-d, block);
}
.cbh:not([data-cbh-ready]) .cbh-toggle {
display: none;
}
.cbh-panel-links a {
display: flex;
align-items: center;
min-height: 48px;
border-bottom: 1px solid var(--cbh-line);
color: inherit;
font-weight: 600;
font-size: 1.0625rem;
text-decoration: none;
}
.cbh-panel-links a[aria-current="page"] {
color: var(--cbh-accent-color);
}
.cbh-panel-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 16px;
}
.cbh-panel-actions .cbh-cta {
display: inline-flex;
}
.cbh-panel-call {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 18px;
border-radius: 999px;
background: var(--cbh-accent-color);
color: var(--cbh-accent-ink-color);
font-weight: 700;
text-decoration: none;
font-variant-numeric: tabular-nums;
}
.cbh a:focus-visible,
.cbh button:focus-visible {
outline: 3px solid var(--cbh-accent-color);
outline-offset: 3px;
}
.cbh-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (min-width: 640px) {
.cbh {
--cbh-pad: 32px;
}
}
@media (min-width: 960px) {
.cbh {
--cbh-h: 84px;
--cbh-pad: var(--cbh-grid-gutter, 32px);
}
.cbh-nav ul {
display: flex;
gap: 4px;
}
.cbh-nav a {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 12px;
color: var(--cbh-muted-color);
white-space: nowrap;
font-weight: 600;
font-size: 0.9375rem;
text-decoration: none;
border-bottom: 2px solid transparent;
}
.cbh-nav a:hover,
.cbh-nav a[aria-current="page"] {
color: var(--cbh-ink-color);
border-bottom-color: var(--cbh-accent-color);
}
.cbh-call-text {
flex-direction: column;
line-height: 1.1;
}
.cbh-call-label {
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.cbh-call-number {
font-size: 1.0625rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.cbh-cta {
display: inline-flex;
}
}
@media (prefers-reduced-motion: no-preference) {
.cbh-nav a,
.cbh-cta,
.cbh-call {
transition:
color 160ms ease,
border-color 160ms ease,
background-color 160ms ease;
}
}
</style>
<script>
/* One controller per header; safe to run again after a client-side navigation. */
function initCallBarHeaders() {
for (const root of document.querySelectorAll<HTMLElement>("[data-cbh-root]:not([data-cbh-ready])")) {
const toggle = root.querySelector<HTMLButtonElement>("[data-cbh-toggle]");
const panel = root.querySelector<HTMLElement>("[data-cbh-panel]");
const bar = root.querySelector<HTMLElement>(".cbh-bar");
root.setAttribute("data-cbh-ready", "");
const setOpen = (open: boolean, returnFocus = false) => {
if (!toggle || !panel) return;
toggle.setAttribute("aria-expanded", String(open));
if (open) panel.setAttribute("data-open", "");
else panel.removeAttribute("data-open");
if (!open && returnFocus) toggle.focus();
};
/*
* Measured fit: the first state whose content fits the bar without overflowing it, in the face the
* page actually loaded. Runs synchronously, so an intermediate state is never painted.
*/
const states = root.querySelector(".cbh-nav") ? ["full", "compact", "menu"] : ["full", "compact"];
let width = -1;
const fit = (force = false) => {
if (!bar) return;
const now = root.clientWidth;
if (!force && now === width) return;
width = now;
for (const state of states) {
root.dataset.fit = state;
if (bar.scrollWidth <= bar.clientWidth + 1) break;
}
/* The pages went inline: an open panel would only repeat them. */
if (toggle && getComputedStyle(toggle).display === "none") setOpen(false);
};
fit(true);
const observer = new ResizeObserver(() => fit());
observer.observe(root);
document.fonts?.ready.then(() => fit(true));
document.addEventListener("astro:before-swap", () => observer.disconnect(), { once: true });
if (!toggle || !panel) continue;
toggle.addEventListener("click", () => setOpen(toggle.getAttribute("aria-expanded") !== "true"));
root.addEventListener("keydown", (event) => {
if (event.key === "Escape" && toggle.getAttribute("aria-expanded") === "true") {
event.preventDefault();
setOpen(false, true);
}
});
panel.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest("a")) setOpen(false);
});
}
}
initCallBarHeaders();
document.addEventListener("astro:page-load", initCallBarHeaders);
</script>
===== END src/components/CallBarHeader.astro =====
===== FILE (supporting): src/components/CallBarHeader.md =====
# Call bar header
A dark, in-flow site header for a business people phone: the name (or logo)
on the left, the site's pages, and the verified number as a real `tel:` link
beside one primary action. Below 960px the number becomes a 44px call button
that stays in the bar, and the pages move into a panel that opens under the
bar, in the flow — it pushes the page down rather than covering it.
Catalog page: `/components/call-bar-header`
## Files required
- **`CallBarHeader.astro`** — the whole component: markup, styles and a small script for the menu.
- `CallBarHeader.md` — this file.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brandName` | `string?` | none | Wordmark (uppercase). Wraps whenever the pages are not inline (below 960px, or a desktop bar in its menu state) so a long name never pushes the actions off-screen. |
| `brandHref` | `string?` | none | Omitted: the brand is not a link. No route is assumed. |
| `logo` | `{ src, alt, width?, height? }?` | none | Replaces the wordmark. |
| `items` | `{ label, href }[]` | `[]` | The site's pages. Unsafe hrefs are dropped. |
| `currentPath` | `string?` | none | Matching link gets `aria-current="page"`. |
| `phone` | `{ number, label? } \| null` | `null` | The verified number. `tel:` is derived from it. Omitted: no call action. |
| `cta` | `{ label, href } \| null` | `null` | One primary action. |
| `navLabel`, `menuLabel` | `string?` | `"Primary"`, `"Menu"` | Accessible names. |
| `fontStylesheet`, `class`, `id` | | | `id` also seeds the panel id. |
With nothing supplied the component renders nothing.
## Behaviour
- In the normal flow (`position: relative`): nothing to reserve; anchors never land under it.
- Desktop fit (960px and up): the bar takes the first state its content fits in — **full** (pages inline, number written out), **compact** (pages inline, number as the 44px call button) or **menu** (pages in the panel; call button and action stay in the bar). A page label never wraps and never runs under the actions. Thresholds are estimated from the content for the first paint and without JavaScript (container queries on the bar's frame, so the site grid and gutter are already accounted for); the script then measures the real bar in its real face and corrects the state. No prop controls it.
- Menu button: `aria-expanded` + `aria-controls`; Escape closes and returns focus; choosing a link closes it; it closes when the pages go inline. Closed panel links are `display: none`, so out of the tab order.
- Without JavaScript the panel is shown open whenever the pages are not inline and the button is hidden, so every page stays reachable.
- Re-initialises on `astro:page-load`; the resize observer is disconnected on `astro:before-swap`.
- Theme with `--cbh-surface`, `--cbh-ink`, `--cbh-muted`, `--cbh-accent`, `--cbh-accent-ink` on an ancestor.
===== END src/components/CallBarHeader.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
No verification run has been recorded.
Passed: integration route, keyboard, no javascript, reduced motion, two instances, no overflow.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- NOT PROVEN ---
- Nothing outstanding in the library's own checks.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.
--- FINISH ---
Run this project's own check and build commands. Report: the layout file you changed, the routes you mapped and what you mapped them to, the CSS collisions you found, what you deleted from the old header, and anything you could not verify. Do not commit and do not deploy.