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 "Solid 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: Solid bar header
Library page: https://astro.baysixmedia.com/components/solid-bar-header/
Navigation mode: dropdown — single-column panels that open from their triggers
Position mode: normal
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:
- In the normal flow, 104px tall at desktop and 80px on a phone, with a 1px hairline under it. Put it first in <body> (after the skip link); nothing needs reserving.
- 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) closes and returns focus to the trigger, as do an outside click, tabbing out and crossing the 1200px breakpoint. Without JavaScript the panels open on hover and focus-within.
- Below 1200px the links and pill fold into a menu button and a white card hung 8px under the bar (absolute, so it covers the page rather than pushing it down). Dropdown groups become disclosures inside it, collapsed once the script runs. Closed card links are visibility:hidden, so out of the tab order. No body scroll lock.
- Without JavaScript the menu button is hidden and the card is shown open under the bar with every group expanded.
- z-index 30 on a position:relative root, so open panels sit over the section below. A following section with its own higher stacking context will cover them.
- An item with `children` renders as a <button>; its own `href` is ignored. Put the parent page in the children if it must be reachable.
- Pass currentPath={Astro.url.pathname} for aria-current; it is not inferred. A trigger turns coral when one of its children is current.
- The demo links (/services/ocean-freight, /request-quote, ...) are placeholders. Map them to your routes.
- Step 39.3: `sticky` pins the bar (position: sticky; top: 0) and keeps z-index 30, so it stays above the page as it scrolls. Keep it a direct child of <body>: an ancestor with overflow other than visible, or a transform, filter or contain, turns sticky into a no-op. Below 1200px it pins only once the script has run.
- Loads Outfit and DM Sans from Google Fonts by default. Pass fontStylesheet="" on a site that already loads them. Theme with --tg-navy, --tg-orange, --tg-muted, --tg-surface, --tg-font-display and --tg-font-body 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 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/SolidBarHeader.astro =====
---
/**
* SolidBarHeader
*
* A 104px white site header in the normal flow: truck mark and wordmark on
* the left, a centred row of links (Home, Services, Pages and News open
* dropdown panels, marked with a small "+"), and an orange "Free Quote!" pill
* with an arrow on the right. Below 1200px the links and the pill fold into
* a menu button and a white card that drops under the bar.
*
* Reads --tg-* design tokens from any ancestor, with fallbacks, so it works
* alone and re-themes with a page.
*
* `sticky` pins the bar to the top of the viewport as the page scrolls
* (position: sticky, so it still holds its own space and nothing is covered
* at rest). Off by default: the reference scrolls away with the page.
*/
export interface SolidBarHeaderLink {
label: string;
href: string;
}
export interface SolidBarHeaderItem {
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?: SolidBarHeaderLink[];
}
export interface SolidBarHeaderProps {
items?: SolidBarHeaderItem[];
/** Pass Astro.url.pathname. Matching links get aria-current="page". */
currentPath?: string;
brandName?: string;
brandHref?: string;
/** Replaces the drawn mark and wordmark with an image. */
logo?: { src: string; alt: string; width?: number; height?: number };
cta?: { label: string; href: string } | null;
navLabel?: string;
menuLabel?: string;
fontStylesheet?: string;
/** Pin the bar to the top of the viewport on scroll. Default false (in the flow). */
sticky?: boolean;
id?: string;
}
interface Props extends SolidBarHeaderProps {}
/* No invented site: links, brand and CTA are all the caller's. Each block
renders only when its data is supplied; with none, nothing renders. */
const {
items = [],
currentPath,
brandName,
brandHref = "/",
logo,
cta = null,
navLabel = "Primary",
menuLabel = "Menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&family=DM+Sans:wght@400;500;600&display=swap",
sticky = false,
id,
} = Astro.props;
/* Ids for aria-controls. Seeded from `id` when given, random otherwise, so a
second instance never collides with the first. */
const seed = id ? id : `sbh-${Math.random().toString(36).slice(2, 8)}`;
const menuId = `${seed}-menu`;
const norm = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
const isCurrent = (href?: string) =>
Boolean(href && currentPath && norm(href) === norm(currentPath));
const hasCurrentChild = (item: SolidBarHeaderItem) =>
Boolean(item.children?.some((c) => isCurrent(c.href)));
const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(cta);
const hasContent = hasBrand || hasMenu;
---
{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header class:list={["sbh", { "sbh--sticky": sticky }]} id={id} data-header-root data-sbh-root>
<div class="sbh-bar">
{hasBrand && (
<a class="sbh-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
{
logo ? (
<img
class="sbh-logo-img"
src={logo.src}
alt={logo.alt}
width={logo.width ?? 157}
height={logo.height ?? 30}
/>
) : (
<>
<svg class="sbh-mark" viewBox="0 0 46 30" aria-hidden="true" focusable="false">
<path d="M2 9h11M5 14h9M1 19h13" fill="none" stroke="var(--sbh-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(--sbh-orange)" />
<circle cx="20" cy="21.5" r="3.4" fill="currentColor" stroke="var(--sbh-bg)" stroke-width="1.6" />
<circle cx="37.5" cy="21.5" r="3.4" fill="currentColor" stroke="var(--sbh-bg)" stroke-width="1.6" />
<path d="M4 27.5h40" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
</svg>
<span class="sbh-wordmark" aria-hidden="true">{brand}</span>
</>
)
}
</a>
)}
{hasItems && (
<nav class="sbh-nav" aria-label={navLabel}>
<ul class="sbh-list" role="list">
{
items.map((item, i) => {
if (item.children && item.children.length > 0) {
const panelId = `${seed}-panel-${i}`;
return (
<li class="sbh-item sbh-item--dd" data-sbh-dd>
<button
type="button"
class="sbh-link sbh-trigger"
aria-expanded="false"
aria-controls={panelId}
data-sbh-trigger
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="sbh-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="sbh-panel" id={panelId} data-sbh-panel>
<ul role="list">
{item.children.map((child) => (
<li>
<a
class="sbh-panel-link"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</div>
</li>
);
}
return (
<li class="sbh-item">
<a
class="sbh-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
)}
{
cta && (
<a class="sbh-cta" href={cta.href}>
<span>{cta.label}</span>
<svg class="sbh-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="sbh-burger"
aria-expanded="false"
aria-controls={menuId}
data-sbh-burger
>
<span class="sbh-sr">{menuLabel}</span>
<span class="sbh-burger-lines" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
)}
</div>
{hasMenu && (
<nav class="sbh-menu" id={menuId} aria-label={navLabel} data-sbh-menu>
{hasItems && (
<ul class="sbh-menu-list" role="list">
{
items.map((item, i) =>
item.children && item.children.length > 0 ? (
<li class="sbh-menu-group" data-sbh-sub>
<button
type="button"
class="sbh-menu-link sbh-menu-toggle"
aria-expanded="true"
aria-controls={`${seed}-sub-${i}`}
data-sbh-subtoggle
data-current={hasCurrentChild(item) ? "" : undefined}
>
{item.label}
<svg class="sbh-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="sbh-sublist" id={`${seed}-sub-${i}`} role="list" data-sbh-sublist>
{item.children.map((child) => (
<li>
<a
class="sbh-menu-link sbh-menu-sublink"
href={child.href}
aria-current={isCurrent(child.href) ? "page" : undefined}
>
{child.label}
</a>
</li>
))}
</ul>
</li>
) : (
<li>
<a
class="sbh-menu-link"
href={item.href ?? "#"}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
</li>
),
)
}
</ul>
)}
{
cta && (
<a class="sbh-cta sbh-cta--menu" href={cta.href}>
<span>{cta.label}</span>
<svg class="sbh-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>
)
}
</nav>
)}
</header>
)}
<style>
.sbh {
--sbh-navy: var(--tg-navy, #020e28);
--sbh-orange: var(--tg-orange, #fd5523);
--sbh-muted: var(--tg-muted, #61697d);
--sbh-surface: var(--tg-surface, #ecf0f1);
--sbh-display: var(--tg-font-display, Outfit, ui-sans-serif, system-ui, sans-serif);
--sbh-body: var(--tg-font-body, "DM Sans", ui-sans-serif, system-ui, sans-serif);
--sbh-bg: #fff;
--sbh-dd-hover: #ff6060;
--sbh-dur: 0.3s;
--sbh-ease: cubic-bezier(0.44, 0, 0.56, 1);
position: relative;
z-index: 30;
box-sizing: border-box;
width: 100%;
padding: 24px 20px;
background: var(--sbh-bg);
border-bottom: 1px solid #e7e9ee;
font-family: var(--sbh-body);
color: var(--sbh-navy);
}
/* Sticky: pinned once the script runs (the menu card then hangs out of the
flow), and always at desktop widths, where there is no card. Without
script the small-screen card sits open in the flow, so the bar is not
pinned there: it would hold the whole menu over the page. */
.sbh--sticky[data-sbh-ready] {
position: sticky;
top: 0;
}
/* A pinned bar would cover whatever an in-page link or the skip link
scrolls to: the page's scroll padding keeps targets below it (the
105px bar with its hairline; 81px on a phone). */
:global(html:has(.sbh--sticky)) {
scroll-padding-top: 105px;
}
.sbh *,
.sbh *::before,
.sbh *::after {
box-sizing: border-box;
}
.sbh ul {
margin: 0;
padding: 0;
list-style: none;
}
.sbh-bar {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 56px;
}
/* ---- brand ---- */
.sbh-brand {
display: inline-flex;
align-items: center;
gap: 9px;
min-height: 44px;
color: var(--sbh-navy);
text-decoration: none;
border-radius: 6px;
flex: none;
}
.sbh-mark {
width: 46px;
height: 30px;
flex: none;
}
.sbh-wordmark {
font: 600 24px/1 var(--sbh-display);
letter-spacing: -0.01em;
}
.sbh-logo-img {
display: block;
height: 30px;
width: auto;
}
/* ---- desktop links ---- */
.sbh-nav {
display: none;
}
/* `.sbh .sbh-list`, not `.sbh-list`: the `.sbh ul` reset would otherwise
outrank it and zero the padding. */
.sbh .sbh-list {
display: flex;
align-items: center;
gap: clamp(26px, calc(6.5vw - 52px), 42px);
padding: 9px 20px;
}
.sbh-item {
position: relative;
}
.sbh-link {
display: inline-flex;
align-items: center;
gap: 1px;
/* 16px type on a 26px line inside a 44px target; the negative margin
gives the space back so the 42px rhythm is the reference's. */
padding: 9px 6px;
margin: -9px -6px;
border: 0;
border-radius: 6px;
background: none;
color: var(--sbh-muted);
font: 500 16px/26px var(--sbh-display);
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: color var(--sbh-dur) var(--sbh-ease);
}
.sbh-link:hover,
.sbh-link[aria-current="page"] {
color: var(--sbh-orange);
}
.sbh-trigger:hover,
.sbh-trigger[aria-expanded="true"],
.sbh-trigger[data-current] {
color: var(--sbh-dd-hover);
}
.sbh-plus {
width: 11px;
height: 11px;
flex: none;
transition: transform var(--sbh-dur) var(--sbh-ease);
}
.sbh-trigger:hover .sbh-plus,
.sbh-trigger[aria-expanded="true"] .sbh-plus {
transform: rotate(180deg);
}
/* ---- dropdown panel ----
Without script the panel opens on hover and focus-within, so every link
stays reachable. Once booted the controller owns the state, and a closed
panel is visibility: hidden — out of the tab order. */
.sbh-panel {
position: absolute;
top: calc(100% + 24px);
left: -22px;
min-width: 220px;
padding: 14px 22px;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 44px rgb(2 14 40 / 0.14);
border-top: 3px solid var(--sbh-orange);
opacity: 0;
transform: translateY(10px);
visibility: hidden;
pointer-events: none;
transition:
opacity var(--sbh-dur) var(--sbh-ease),
transform var(--sbh-dur) var(--sbh-ease),
visibility 0s linear var(--sbh-dur);
z-index: 2;
}
/* A bridge over the gap between trigger and panel, so pointer travel
from one to the other never crosses dead space. */
.sbh-panel::before {
content: "";
position: absolute;
inset: -27px 0 100% 0;
}
.sbh:not([data-sbh-ready]) .sbh-item--dd:hover .sbh-panel,
.sbh:not([data-sbh-ready]) .sbh-item--dd:focus-within .sbh-panel,
.sbh[data-sbh-ready] .sbh-item--dd[data-open] .sbh-panel {
opacity: 1;
transform: none;
visibility: visible;
pointer-events: auto;
transition:
opacity var(--sbh-dur) var(--sbh-ease),
transform var(--sbh-dur) var(--sbh-ease),
visibility 0s;
}
.sbh-panel-link {
display: flex;
align-items: center;
min-height: 40px;
color: var(--sbh-navy);
font: 500 16px/1.3 var(--sbh-display);
text-decoration: none;
white-space: nowrap;
border-radius: 4px;
transition:
color var(--sbh-dur) var(--sbh-ease),
transform var(--sbh-dur) var(--sbh-ease);
}
.sbh-panel-link:hover,
.sbh-panel-link[aria-current="page"] {
color: var(--sbh-orange);
}
.sbh-panel-link:hover {
transform: translateX(4px);
}
/* ---- Free Quote pill ---- */
.sbh-cta {
display: none;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 30px;
border-radius: 100px;
background: var(--sbh-orange);
color: #fff;
font: 600 14px/1 var(--sbh-body);
text-decoration: none;
white-space: nowrap;
flex: none;
transition: background-color var(--sbh-dur) var(--sbh-ease);
}
.sbh-arrow {
width: 12px;
height: 12px;
flex: none;
transition: transform var(--sbh-dur) var(--sbh-ease);
}
.sbh-cta:hover {
background: var(--sbh-navy);
}
.sbh-cta:hover .sbh-arrow {
transform: rotate(45deg);
}
/* ---- burger ---- */
.sbh-burger {
display: grid;
place-items: center;
width: 44px;
height: 44px;
margin-right: -8px;
padding: 0;
border: 0;
border-radius: 8px;
background: none;
color: var(--sbh-navy);
cursor: pointer;
}
/* No script, no toggle: the menu is simply shown under the bar, so a
button that could do nothing is not offered. */
.sbh:not([data-sbh-ready]) .sbh-burger {
display: none;
}
.sbh-burger-lines {
position: relative;
display: block;
width: 24px;
height: 18px;
}
.sbh-burger-lines span {
position: absolute;
left: 0;
width: 24px;
height: 2px;
border-radius: 1px;
background: currentColor;
transition:
transform var(--sbh-dur) var(--sbh-ease),
opacity var(--sbh-dur) var(--sbh-ease);
}
.sbh-burger-lines span:nth-child(1) { top: 0; }
.sbh-burger-lines span:nth-child(2) { top: 8px; }
.sbh-burger-lines span:nth-child(3) { top: 16px; }
.sbh[data-sbh-open] .sbh-burger-lines span:nth-child(1) { transform: translateY(8px) rotate(45deg); }
.sbh[data-sbh-open] .sbh-burger-lines span:nth-child(2) { opacity: 0; }
.sbh[data-sbh-open] .sbh-burger-lines span:nth-child(3) { transform: translateY(-8px) rotate(-45deg); }
/* ---- phone / tablet menu card ---- */
.sbh-menu {
margin: 16px 0 0;
padding: 12px 20px 20px;
border-radius: 12px;
background: #fff;
box-shadow: 0 18px 44px rgb(2 14 40 / 0.14);
border: 1px solid #e7e9ee;
transition:
opacity var(--sbh-dur) var(--sbh-ease),
transform var(--sbh-dur) var(--sbh-ease),
visibility 0s linear var(--sbh-dur);
}
/* With script the card hangs under the bar, out of the flow, so opening
it covers the page rather than pushing it down. */
.sbh[data-sbh-ready] .sbh-menu {
position: absolute;
top: calc(100% + 8px);
left: 16px;
right: 16px;
margin: 0;
max-height: calc(100vh - 120px);
max-height: calc(100dvh - 120px);
overflow-y: auto;
}
.sbh[data-sbh-ready]:not([data-sbh-open]) .sbh-menu {
opacity: 0;
transform: translateY(-8px);
visibility: hidden;
pointer-events: none;
}
.sbh[data-sbh-ready][data-sbh-open] .sbh-menu {
transition:
opacity var(--sbh-dur) var(--sbh-ease),
transform var(--sbh-dur) var(--sbh-ease),
visibility 0s;
}
.sbh-menu-list > li + li {
border-top: 1px solid #eceef2;
}
.sbh-menu-link {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 48px;
padding: 0;
border: 0;
background: none;
color: var(--sbh-navy);
font: 500 17px/1.2 var(--sbh-display);
text-align: left;
text-decoration: none;
cursor: pointer;
border-radius: 4px;
transition: color var(--sbh-dur) var(--sbh-ease);
}
.sbh-menu-link:hover,
.sbh-menu-link[aria-current="page"] {
color: var(--sbh-orange);
}
.sbh-menu-toggle:hover,
.sbh-menu-toggle[data-current] {
color: var(--sbh-dd-hover);
}
.sbh-menu-toggle .sbh-plus {
width: 13px;
height: 13px;
}
.sbh-menu-toggle[aria-expanded="true"] .sbh-plus {
transform: rotate(45deg);
}
.sbh .sbh-sublist {
padding: 0 0 10px 14px;
border-left: 2px solid var(--sbh-surface);
margin-left: 2px;
}
.sbh-sublist[hidden] {
display: none;
}
.sbh-menu-sublink {
min-height: 44px;
color: var(--sbh-muted);
font: 400 16px/1.3 var(--sbh-body);
}
.sbh-cta--menu {
display: flex;
justify-content: center;
margin-top: 14px;
}
/* ---- focus ---- */
.sbh a:focus-visible,
.sbh button:focus-visible {
outline: 2px solid var(--sbh-orange);
outline-offset: 3px;
}
.sbh-cta:focus-visible {
outline-color: var(--sbh-navy);
}
.sbh-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ---- phone: an 80px bar ---- */
@media (max-width: 809.98px) {
:global(html:has(.sbh--sticky)) {
scroll-padding-top: 81px;
}
.sbh {
padding: 12px 20px;
}
.sbh-mark {
width: 40px;
height: 26px;
}
.sbh-wordmark {
font-size: 22px;
}
}
/* ---- desktop: links and pill appear, the menu goes ---- */
@media (min-width: 1200px) {
.sbh--sticky {
position: sticky;
top: 0;
}
.sbh-nav {
display: block;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.sbh-cta {
display: inline-flex;
}
.sbh-burger,
.sbh .sbh-menu {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sbh {
--sbh-dur: 1ms;
}
.sbh-trigger:hover .sbh-plus,
.sbh-cta:hover .sbh-arrow,
.sbh-panel-link:hover {
transform: none;
}
.sbh-trigger[aria-expanded="true"] .sbh-plus {
transform: none;
}
}
@media (forced-colors: active) {
.sbh-panel,
.sbh-menu,
.sbh-cta {
border: 1px solid CanvasText;
}
}
</style>
<script>
/**
* SolidBarHeader controller.
*
* One closure per instance, every lookup scoped to that instance's root,
* every listener on an AbortController aborted before a client-side
* navigation swaps the document. No body scroll lock: the small-screen
* menu is a card under the bar, not a sheet over the page.
*/
function initSolidBarHeader(root: HTMLElement) {
/* Idempotent: a second boot over the same element is a no-op. */
if (root.dataset.sbhReady === "") return;
const burger = root.querySelector<HTMLButtonElement>("[data-sbh-burger]");
const menu = root.querySelector<HTMLElement>("[data-sbh-menu]");
const dropdowns = [...root.querySelectorAll<HTMLElement>("[data-sbh-dd]")];
const subs = [...root.querySelectorAll<HTMLElement>("[data-sbh-sub]")];
root.dataset.sbhReady = "";
const controller = new AbortController();
const { signal } = controller;
const desktop = window.matchMedia("(min-width: 1200px)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
/* ---- desktop dropdowns: one open at a time, per instance ---- */
let openDd: HTMLElement | null = null;
let openedByHover = false;
let hoverTimer = 0;
const triggerOf = (dd: HTMLElement) =>
dd.querySelector<HTMLButtonElement>("[data-sbh-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",
() => {
/* A click on a panel the pointer already opened pins it open
rather than snapping it shut under the cursor. */
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 },
);
/* Focus leaving the item entirely closes it, so Tab past the last
link does not leave a panel hanging open behind the focus. */
dd.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (openDd === dd && next && !dd.contains(next)) closeDropdown();
},
{ signal },
);
});
/* ---- small-screen menu ---- */
let menuOpen = false;
function setMenu(next: boolean, returnFocus = false) {
if (!burger || !menu || menuOpen === next) return;
menuOpen = next;
root.toggleAttribute("data-sbh-open", next);
burger.setAttribute("aria-expanded", String(next));
if (!next && returnFocus) burger.focus({ preventScroll: true });
}
/* Sub-lists start collapsed once the script runs; without it they are
all shown, so every link stays reachable. */
subs.forEach((sub) => {
const toggle = sub.querySelector<HTMLButtonElement>("[data-sbh-subtoggle]");
const list = sub.querySelector<HTMLElement>("[data-sbh-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 });
/* Choosing a route closes the menu. */
menu?.addEventListener(
"click",
(event) => {
if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
},
{ signal },
);
/* Escape is bound to the document, not the root: after a client-side
navigation focus sits outside the header, and Escape must still work. */
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 },
);
/* Outside click closes whichever is open. */
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (openDd && !openDd.contains(target)) closeDropdown();
if (menuOpen && !root.contains(target)) setMenu(false);
},
{ signal },
);
/* Tabbing out of the open menu closes it. */
menu?.addEventListener(
"focusout",
(event) => {
const next = event.relatedTarget as Node | null;
if (menuOpen && next && !root.contains(next)) setMenu(false);
},
{ signal },
);
/* Crossing the breakpoint resets both. */
desktop.addEventListener(
"change",
() => {
setMenu(false);
closeDropdown();
},
{ signal },
);
function destroy() {
closeDropdown();
setMenu(false);
controller.abort();
delete root.dataset.sbhReady;
}
document.addEventListener("astro:before-swap", destroy, { once: true, signal });
}
const boot = () =>
document.querySelectorAll<HTMLElement>("[data-sbh-root]").forEach(initSolidBarHeader);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
/* Registered once per module, so it cannot accumulate. */
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/SolidBarHeader.astro =====
===== FILE (supporting): src/components/SolidBarHeader.md =====
# Solid bar header
A 104px white site header in the normal flow: truck mark and wordmark on the
left, a centred row of links where Home, Services, Pages and News open
dropdown panels, and an orange "Free Quote!" pill on the right. Below 1200px
the links and pill fold into a menu button and a white card.
Catalog page: `/components/solid-bar-header`
Its sibling for inner pages is `ContactBarOverlayHeader`: same items, same
dropdown and menu behaviour, laid over a photo banner. The two share nothing
at runtime; each is one self-contained file.
## Files required
**`SolidBarHeader.astro`** — the whole component. No imports, no packages, no
images (the mark is inline SVG). `SolidBarHeader.md` — this file, optional.
## Basic usage
```astro
---
import SolidBarHeader from "../components/SolidBarHeader.astro";
---
<body>
<a class="skip" href="#main">Skip to main content</a>
<SolidBarHeader
currentPath={Astro.url.pathname}
brandName="Your Business"
items={[
{ label: "Services", children: [
{ label: "Service one", href: "/services/one" },
{ label: "Service two", href: "/services/two" },
] },
{ label: "About", href: "/about" },
{ label: "Contact", href: "/contact" },
]}
cta={{ label: "Get a quote", href: "/contact" }}
/>
<main id="main">...</main>
</body>
```
It sits in the flow (`position: relative`, z-index 30) and scrolls away with
the page. Nothing needs reserving.
## Props
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `items` | `SolidBarHeaderItem[]?` | `[]` | `{ label, href?, children? }`. An item with `children` becomes a dropdown `<button>`; its `href` is ignored. |
| `currentPath` | `string?` | — | Pass `Astro.url.pathname`. Matching links get `aria-current="page"`; a trigger with a current child turns coral. |
| `brandName` | `string?` | — | Wordmark and the brand link's accessible name. No name and no `logo`: no brand link. |
| `brandHref` | `string?` | `"/"` | |
| `logo` | `{ src, alt, width?, height? }?` | — | Replaces the drawn mark and wordmark (157×30 by default). |
| `cta` | `{ label, href } \| null` | `null` | Omitted or `null`: no pill. |
| `navLabel` | `string?` | `"Primary"` | Accessible name of both nav landmarks. |
| `menuLabel` | `string?` | `"Menu"` | Hidden label of the menu button. |
| `fontStylesheet` | `string?` | Outfit + DM Sans | `""` skips it. |
| `sticky` | `boolean?` | `false` | Pins the bar to the top of the viewport on scroll (`position: sticky; top: 0`). It still holds its own space. Below 1200px it pins only once the script runs, because without script the menu card sits open in the flow. |
| `id` | `string?` | — | Root id; also seeds the `aria-controls` ids. |
No business data is built in: brand, links and pill are all yours. Each
renders only when supplied, the menu button only when there are links or a
pill to put in the card, and with none of the three the header renders
nothing. The catalog preview passes the demo "Northpass" brand, link tree and
pill as props.
Theme with `--tg-navy`, `--tg-orange`, `--tg-muted`, `--tg-surface`,
`--tg-font-display`, `--tg-font-body` on any ancestor.
## Behaviour
Measured on the reference and reproduced:
| Element | Rest | Hover / open |
| :--- | :--- | :--- |
| Plain nav link | `#61697d`, Outfit 500 16/26 | `#fd5523` |
| Dropdown trigger | `#61697d` with an 11px "+" | `#ff6060`, the "+" rotates 180° |
| Free Quote pill | `#fd5523`, 56px tall, 30px sides | background `#020e28`, the ↗ arrow rotates 45° |
Transitions are 0.3s `cubic-bezier(.44,0,.56,1)`.
**Dropdowns.** A white panel with an orange top edge, 24px under the bar.
They open on mouse hover (fine pointers only, 160ms grace on leave), on
click, and on Enter/Space. A click on a panel that hover opened pins it open
rather than snapping it shut. One open at a time. Escape (bound to the
document) closes and returns focus to the trigger; so do an outside click,
tabbing out of the item, and crossing 1200px. Panel links nudge 4px right
and turn orange on hover.
**Small screens (< 1200px).** Logo and a menu button (80px bar on a phone).
The button opens a white card hung under the bar, absolute so it covers the
page rather than pushing it. Dropdown groups become disclosures inside it,
collapsed when the script boots. Escape closes it and returns focus to the
button; choosing a link, an outside click, tabbing out and crossing to
desktop close it too. Closed, the card is `visibility: hidden`, so none of
its links are in the tab order. No body scroll lock.
**No JavaScript.** Dropdown panels open on hover and `focus-within`; the menu
button is hidden and the card is shown open under the bar with every group
expanded.
**Reduced motion.** Durations drop to 1ms and the "+", arrow and panel-link
transforms are off; the colour changes stay.
## Accessibility
- `<header>` with a `<nav aria-label>`; the small-screen card is a second
`<nav>` with the same name, only one of which is ever displayed.
- Triggers are real buttons with `aria-expanded` and `aria-controls`, kept in
sync with the visible state. Menu disclosures likewise.
- Every target is at least 44px (nav links keep their 42px rhythm with a
negative margin). Visible 2px orange focus ring.
- Script: one closure per root, idempotent `data-sbh-ready`, every listener
on an `AbortController` aborted on `astro:before-swap`; boots immediately
and on `astro:page-load`. It never scrolls the window or moves focus on load.
## Images and credits
None. The truck mark is an original inline SVG.
## Approximations
- **Dropdown panels were not captured open** on the reference. Their look
(white card, orange top edge, 220px min width) and their links are
invented; open-on-hover-and-click is a reasonable reading.
- **The 1px hairline** under the bar is read from the screenshot (`#e7e9ee`);
the dump shows none.
- **No tablet capture.** The fold to a menu button happens below 1200px
(Framer's desktop breakpoint); the phone bar is 80px as measured.
- The nav gap narrows from 42px toward 26px between 1440 and 1200px so the
centred row never touches the logo or pill.
===== END src/components/SolidBarHeader.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.