Component library
Back to catalog

Solid bar header

A 104px white header in the page flow: truck mark and wordmark, a centred row of links where Home, Services, Pages and News open dropdown panels (their "+" turns and the label goes coral on hover), and an orange "Free Quote!" pill whose arrow turns on hover. Folds to a menu button and a white card below 1200px.

navheaderdropdownwhitepilllogistics

Open preview in a new tab

Use this component

Three ways to get Solid 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 Solid bar header from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/solid-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 ---
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.

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
“Solid bar header” from my Astro Component Library:
https://astro.baysixmedia.com/components/solid-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/SolidBarHeader.astro

Installing this one is more than pasting a section

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