Component library
Back to catalog

Glass nav header

A 1170px dark glass bar floating over the hero: logo and wordmark, centred links, a Contact link and a lime "Get a quote" pill that swells on hover. Folds to a menu button and a glass card below 960px.

navheaderoverlayglasspill

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/glass-nav-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
“Glass nav header” from my Astro Component Library:
https://astro.baysixmedia.com/components/glass-nav-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/GlassNavHeader.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/GlassNavHeader.astrocomponentrequired
    • src/components/GlassNavHeader.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * GlassNavHeader
 *
 * A 1170px dark glass bar floating over a hero: logo mark and wordmark on
 * the left, links in the middle, a "Contact us" text link and a lime quote
 * pill on the right. Below 960px it folds to the logo and a real menu
 * button that opens a glass card under the bar.
 *
 * `overlay` (default true) positions it absolutely at `top` inside its
 * containing block — 52px by default, which clears a 38px ticker strip
 * with the measured 14px gap. `overlay={false}` puts it in the flow on an
 * ink band.
 *
 * Reads --nv-* tokens from any ancestor, with fallbacks.
 */

export interface GlassNavHeaderItem {
	label: string;
	href: string;
}

export interface GlassNavHeaderProps {
	items?: GlassNavHeaderItem[];
	/** 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 };
	/** The plain text link beside the pill. `null` drops it. */
	secondary?: { label: string; href: string } | null;
	/** The lime pill. `null` drops it. */
	cta?: { label: string; href: string } | null;
	/** Absolute over the hero (true) or in the flow on an ink band (false). */
	overlay?: boolean;
	/** Offset from the top of the containing block in overlay mode, desktop and tablet. */
	top?: string;
	/** The same below 810px, where the ticker is 36px and the gap 12px. */
	topPhone?: string;
	navLabel?: string;
	menuLabel?: string;
	fontStylesheet?: string;
	id?: string;
}

interface Props extends GlassNavHeaderProps {}

/* No invented site: links, brand, text link and pill are all the caller's.
   Each block renders only when supplied; with none, nothing renders. */
const {
	items = [],
	currentPath,
	brandName,
	brandHref = "/",
	logo,
	secondary = null,
	cta = null,
	overlay = true,
	top = "52px",
	topPhone = "48px",
	navLabel = "Primary",
	menuLabel = "Menu",
	fontStylesheet = "https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700;800&display=swap",
	id,
} = Astro.props;

/* Ids for aria-controls, seeded from `id` or a random suffix so a second
   instance never collides with the first. */
const seed = id ? id : `gnh-${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 style = `--gnh-top:${top};--gnh-top-phone:${topPhone}`;

const brand = brandName?.trim() ?? "";
const hasBrand = Boolean(logo || brand);
const hasItems = items.length > 0;
const hasMenu = hasItems || Boolean(secondary || cta);
const hasContent = hasBrand || hasMenu;
---

{hasContent && fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
{hasContent && (
<header
	class:list={["gnh", overlay ? "gnh--overlay" : "gnh--flow"]}
	id={id}
	style={style}
	data-header-root
	data-gnh-root
>
	<div class="gnh-inner">
		<div class="gnh-bar">
			{hasBrand && (
			<a class="gnh-brand" href={brandHref} aria-label={logo ? undefined : `${brand} home`}>
				{
					logo ? (
						<img
							class="gnh-logo-img"
							src={logo.src}
							alt={logo.alt}
							width={logo.width ?? 110}
							height={logo.height ?? 26}
						/>
					) : (
						<>
							<svg class="gnh-mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
								<rect x="4" y="4" width="7" height="24" rx="2" fill="var(--nv-lime, #c8ff38)" />
								<rect x="21" y="4" width="7" height="24" rx="2" fill="#fff" />
								<path d="M11 18.5 21 10v6.2L11 24.7z" fill="var(--nv-lime, #c8ff38)" />
							</svg>
							<span class="gnh-wordmark" aria-hidden="true">{brand}</span>
						</>
					)
				}
			</a>
			)}

			{hasItems && (
			<nav class="gnh-nav" aria-label={navLabel}>
				<ul class="gnh-links" role="list">
					{
						items.map((item) => (
							<li>
								<a
									class="gnh-link"
									href={item.href}
									aria-current={isCurrent(item.href) ? "page" : undefined}
								>
									{item.label}
								</a>
							</li>
						))
					}
				</ul>
			</nav>
			)}

			{
				(secondary || cta) && (
					<div class="gnh-actions">
						{secondary && (
							<a
								class="gnh-secondary"
								href={secondary.href}
								aria-current={isCurrent(secondary.href) ? "page" : undefined}
							>
								{secondary.label}
							</a>
						)}
						{cta && (
							<a class="gnh-cta" href={cta.href}>
								<span>{cta.label}</span>
								<svg class="gnh-arrow" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
									<path d="M2.5 8h10M9 4.5 12.5 8 9 11.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" />
								</svg>
							</a>
						)}
					</div>
				)
			}

			{hasMenu && (
			<button
				type="button"
				class="gnh-burger"
				aria-expanded="false"
				aria-controls={menuId}
				data-gnh-burger
			>
				<span class="gnh-sr">{menuLabel}</span>
				<svg class="gnh-icon gnh-icon--open" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
					<path d="M4 7h16M4 12h16M4 17h16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
				</svg>
				<svg class="gnh-icon gnh-icon--close" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
					<path d="M6 6l12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
				</svg>
			</button>
			)}
		</div>

		{hasMenu && (
		<nav class="gnh-menu" id={menuId} aria-label={navLabel} data-gnh-menu>
			{(hasItems || secondary) && (
			<ul class="gnh-menu-list" role="list">
				{
					items.map((item) => (
						<li>
							<a
								class="gnh-menu-link"
								href={item.href}
								aria-current={isCurrent(item.href) ? "page" : undefined}
							>
								{item.label}
							</a>
						</li>
					))
				}
				{
					secondary && (
						<li>
							<a
								class="gnh-menu-link"
								href={secondary.href}
								aria-current={isCurrent(secondary.href) ? "page" : undefined}
							>
								{secondary.label}
							</a>
						</li>
					)
				}
			</ul>
			)}
			{cta && <a class="gnh-menu-cta" href={cta.href}>{cta.label}</a>}
		</nav>
		)}
	</div>
</header>
)}

<style>
	.gnh {
		--gnh-ink: var(--nv-ink, #08110f);
		--gnh-lime: #b7ff39;
		--gnh-font: var(--nv-font, Manrope, ui-sans-serif, system-ui, sans-serif);
		--gnh-dur: 0.3s;
		--gnh-ease: cubic-bezier(0.22, 1, 0.36, 1);

		box-sizing: border-box;
		width: 100%;
		padding: 0 20px;
		font-family: var(--gnh-font);
		color: #fff;
		z-index: 30;
	}

	.gnh *,
	.gnh *::before,
	.gnh *::after {
		box-sizing: border-box;
	}

	.gnh ul {
		margin: 0;
		padding: 0;
		list-style: none;
	}

	.gnh--overlay {
		position: absolute;
		top: var(--gnh-top, 52px);
		left: 0;
		right: 0;
		background: transparent;
	}

	.gnh--flow {
		position: relative;
		padding-top: 14px;
		padding-bottom: 14px;
		background: var(--gnh-ink);
	}

	.gnh-inner {
		position: relative;
		max-width: 1170px;
		margin: 0 auto;
	}

	/* ---- the glass bar ---- */
	.gnh-bar {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: space-between;
		gap: 16px;
		height: 58px;
		padding: 0 12px;
		border: 1px solid rgb(255 255 255 / 0.1);
		border-radius: 18px;
		background: rgb(8 17 15 / 0.66);
		box-shadow: 0 16px 36px rgb(0 0 0 / 0.22);
		-webkit-backdrop-filter: blur(22px);
		backdrop-filter: blur(22px);
	}

	/* ---- brand ---- */
	.gnh-brand {
		display: inline-flex;
		align-items: center;
		gap: 10px;
		min-height: 44px;
		color: #fff;
		text-decoration: none;
		border-radius: 8px;
		flex: none;
	}

	.gnh-mark {
		display: block;
		width: 26px;
		height: 26px;
		flex: none;
	}

	.gnh-wordmark {
		font-size: 15px;
		font-weight: 800;
		line-height: 15px;
		letter-spacing: -0.04em;
		text-transform: uppercase;
	}

	.gnh-logo-img {
		display: block;
		height: 26px;
		width: auto;
	}

	/* ---- links (desktop) ---- */
	.gnh-nav,
	.gnh-actions {
		display: none;
	}

	.gnh .gnh-links {
		display: flex;
		align-items: center;
		gap: 30px;
	}

	.gnh-link {
		display: inline-flex;
		align-items: center;
		/* 14px type in a 44px target; the negative margin hands the space
		   back so the measured 30px rhythm holds. */
		padding: 15px 4px;
		margin: -15px -4px;
		border-radius: 8px;
		color: rgb(255 255 255 / 0.76);
		font-size: 14px;
		font-weight: 700;
		line-height: 14px;
		text-decoration: none;
		white-space: nowrap;
	}

	/* No hover change was measured on these links; the current page is
	   marked in full white with an underline. */
	.gnh-link[aria-current="page"] {
		color: #fff;
		text-decoration: underline;
		text-decoration-color: var(--gnh-lime);
		text-decoration-thickness: 2px;
		text-underline-offset: 6px;
	}

	/* ---- actions ---- */
	.gnh-actions {
		align-items: center;
		gap: 10px;
		flex: none;
	}

	.gnh-secondary {
		display: inline-flex;
		align-items: center;
		min-height: 44px;
		padding: 0 4px;
		border-radius: 8px;
		color: #fff;
		font-size: 13px;
		font-weight: 800;
		line-height: 13px;
		text-decoration: none;
		white-space: nowrap;
	}

	.gnh-cta {
		display: inline-flex;
		align-items: center;
		gap: 6px;
		height: 46px;
		padding: 0 18px;
		border-radius: 100px;
		background: var(--gnh-lime);
		color: var(--gnh-ink);
		font-size: 13px;
		font-weight: 800;
		line-height: 13px;
		text-decoration: none;
		white-space: nowrap;
		/* The hover grows lime around the label with a spread shadow in the
		   pill's own colour, so the pill swells without reflowing the bar. */
		box-shadow: 0 0 0 0 var(--gnh-lime);
		transition: box-shadow var(--gnh-dur) var(--gnh-ease), background-color var(--gnh-dur) var(--gnh-ease);
	}

	.gnh-cta:hover {
		box-shadow: 0 0 0 5px var(--gnh-lime);
	}

	.gnh-arrow {
		width: 14px;
		height: 14px;
		flex: none;
	}

	/* ---- menu button ---- */
	.gnh-burger {
		display: grid;
		place-items: center;
		width: 44px;
		height: 44px;
		padding: 0;
		border: 0;
		border-radius: 12px;
		background: rgb(255 255 255 / 0.1);
		color: #fff;
		cursor: pointer;
		flex: none;
		transition: background-color var(--gnh-dur) var(--gnh-ease);
	}

	.gnh-burger:hover {
		background: rgb(255 255 255 / 0.16);
	}

	.gnh-icon {
		grid-area: 1 / 1;
		width: 24px;
		height: 24px;
	}

	.gnh-burger[aria-expanded="true"] .gnh-icon--open,
	.gnh-burger[aria-expanded="false"] .gnh-icon--close {
		display: none;
	}

	/* No script, no toggle: the card is shown open under the bar instead, so
	   a button that could do nothing is not offered. */
	.gnh:not([data-gnh-ready]) .gnh-burger {
		display: none;
	}

	/* ---- menu card ---- */
	.gnh-menu {
		display: flex;
		flex-direction: column;
		gap: 8px;
		margin-top: 14px;
		padding: 14px 20px 20px;
		border: 1px solid rgb(255 255 255 / 0.1);
		border-radius: 24px;
		background: rgb(8 17 15 / 0.72);
		box-shadow: 0 16px 36px rgb(0 0 0 / 0.22);
		-webkit-backdrop-filter: blur(22px);
		backdrop-filter: blur(22px);
	}

	/* With script the card hangs under the bar out of the flow, covering the
	   hero rather than pushing the page down, and a closed card is
	   visibility: hidden — out of the tab order and the accessibility tree. */
	.gnh[data-gnh-ready] .gnh-menu {
		position: absolute;
		top: calc(100% + 14px);
		left: 0;
		right: 0;
		margin: 0;
		transition:
			opacity 0.22s var(--gnh-ease),
			transform 0.22s var(--gnh-ease),
			visibility 0s linear 0.22s;
	}

	.gnh[data-gnh-ready]:not([data-gnh-open]) .gnh-menu {
		opacity: 0;
		transform: translateY(-6px);
		visibility: hidden;
		pointer-events: none;
	}

	.gnh[data-gnh-ready][data-gnh-open] .gnh-menu {
		transition:
			opacity 0.22s var(--gnh-ease),
			transform 0.22s var(--gnh-ease),
			visibility 0s;
	}

	.gnh-menu-list {
		display: flex;
		flex-direction: column;
	}

	.gnh-menu-link {
		display: flex;
		align-items: center;
		min-height: 44px;
		border-radius: 8px;
		color: rgb(255 255 255 / 0.88);
		font-size: 15px;
		font-weight: 700;
		line-height: 15px;
		text-decoration: none;
	}

	.gnh-menu-link[aria-current="page"] {
		color: var(--gnh-lime);
	}

	.gnh-menu-cta {
		display: flex;
		align-items: center;
		justify-content: center;
		min-height: 44px;
		padding: 14px 16px;
		border-radius: 12px;
		background: linear-gradient(100deg, #c8ff38 0%, #73f5df 52%, #ffb86b 100%);
		color: var(--gnh-ink);
		font-size: 15px;
		font-weight: 800;
		line-height: 15px;
		text-decoration: none;
	}

	/* ---- focus ---- */
	.gnh a:focus-visible,
	.gnh button:focus-visible {
		outline: 2px solid var(--gnh-lime);
		outline-offset: 2px;
	}

	.gnh-link:focus-visible,
	.gnh-secondary:focus-visible {
		color: #fff;
	}

	.gnh-cta:focus-visible,
	.gnh-menu-cta:focus-visible {
		outline-color: #fff;
		outline-offset: 3px;
	}

	.gnh-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: the reference's own offsets ---- */
	@media (max-width: 809.98px) {
		.gnh {
			padding: 0 12px;
		}

		.gnh--overlay {
			top: var(--gnh-top-phone, 48px);
		}

		.gnh--flow {
			padding-top: 12px;
			padding-bottom: 12px;
		}
	}

	/* ---- desktop: the links and actions appear, the menu goes ---- */
	@media (min-width: 960px) {
		.gnh-bar {
			height: 64px;
			padding: 0 18px;
			border-radius: 22px;
			background: rgb(8 17 15 / 0.62);
		}

		.gnh-wordmark {
			font-size: 18px;
			line-height: 18px;
		}

		.gnh-nav {
			display: block;
			position: absolute;
			left: 50%;
			transform: translateX(-50%);
		}

		.gnh-actions {
			display: flex;
		}

		.gnh-burger,
		.gnh .gnh-menu {
			display: none;
		}
	}

	/* Between 960px and 1100px the centred links would run into the
	   actions; let them sit in the flow between the brand and the actions. */
	@media (min-width: 960px) and (max-width: 1099.98px) {
		.gnh-nav {
			position: static;
			transform: none;
		}

		.gnh .gnh-links {
			gap: 24px;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.gnh {
			--gnh-dur: 1ms;
		}

		.gnh[data-gnh-ready] .gnh-menu {
			transform: none !important;
		}
	}

	@media (forced-colors: active) {
		.gnh-bar,
		.gnh-menu {
			border-color: CanvasText;
		}
	}
</style>

<script>
	/**
	 * GlassNavHeader controller: the phone menu only.
	 *
	 * One closure per instance, every lookup scoped to its root, every
	 * listener on an AbortController aborted before a client-side navigation
	 * swaps the document. No body scroll lock: the menu is a card under the
	 * bar, not a sheet over the page. Never moves focus except in response
	 * to the user closing the menu.
	 */
	function initGlassNavHeader(root: HTMLElement) {
		if (root.dataset.gnhReady === "") return;

		const burger = root.querySelector<HTMLButtonElement>("[data-gnh-burger]");
		const menu = root.querySelector<HTMLElement>("[data-gnh-menu]");
		if (!burger || !menu) return;

		root.dataset.gnhReady = "";
		const controller = new AbortController();
		const { signal } = controller;
		const desktop = window.matchMedia("(min-width: 960px)");

		let open = false;

		function setMenu(next: boolean, returnFocus = false) {
			if (open === next) return;
			open = next;
			root.toggleAttribute("data-gnh-open", next);
			burger!.setAttribute("aria-expanded", String(next));
			if (!next && returnFocus) burger!.focus({ preventScroll: true });
		}

		burger.addEventListener("click", () => setMenu(!open), { signal });

		/* Choosing a destination closes the menu. */
		menu.addEventListener(
			"click",
			(event) => {
				if ((event.target as HTMLElement).closest("a[href]")) setMenu(false);
			},
			{ signal },
		);

		/* Escape on the document, not the root: after a client-side navigation
		   focus can sit outside the header and Escape must still close it. */
		document.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "Escape" || !open) return;
				event.preventDefault();
				setMenu(false, true);
			},
			{ signal },
		);

		/* Outside click closes. */
		document.addEventListener(
			"pointerdown",
			(event) => {
				if (open && !root.contains(event.target as Node)) setMenu(false);
			},
			{ signal },
		);

		/* Focus leaving the header entirely (Tab past the last link) closes it,
		   so an open card never hangs behind the focus. */
		root.addEventListener(
			"focusout",
			(event) => {
				const next = event.relatedTarget as Node | null;
				if (open && next && !root.contains(next)) setMenu(false);
			},
			{ signal },
		);

		/* Crossing to desktop resets: the menu must not be waiting open the next
		   time the viewport narrows. */
		desktop.addEventListener("change", () => setMenu(false), { signal });

		function destroy() {
			setMenu(false);
			controller.abort();
			delete root.dataset.gnhReady;
		}

		document.addEventListener("astro:before-swap", destroy, { once: true, signal });
	}

	const boot = () =>
		document.querySelectorAll<HTMLElement>("[data-gnh-root]").forEach(initGlassNavHeader);

	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>