Component library
Back to catalog

Call bar header

A dark, in-flow header for a business people phone: name or logo, the site's pages, and the verified number as a real call link beside one action. Below 960px the number stays in the bar as a call button and the pages open in a panel under it.

navheaderphonecalllocal-servicedark

Open preview in a new tab

Use this component

Three ways to get Call bar header into one of your own projects. All pre-filled with this component's name and library page.

Or, for the design alone

Placement prompt

What to do, where it goes and what must not change. Paste it into Claude Code inside the destination project.

Read it first
Add Call bar header from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/call-bar-header/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

--- 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.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Call bar header” from my Astro Component Library:
https://astro.baysixmedia.com/components/call-bar-header/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

src/components/library/CallBarHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/CallBarHeader.astrocomponentrequired
    • src/components/CallBarHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * 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>