Component library
Back to catalog

Expanding pill nav

A floating glass navigation pill that widens once the page scrolls, and collapses its links behind a toggle on small screens.

navheaderstickyglassscroll

Open preview in a new tab

Use this component

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

Library page, for reference only:
https://astro.baysixmedia.com/components/expanding-pill-nav/

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
“Expanding pill nav” from my Astro Component Library:
https://astro.baysixmedia.com/components/expanding-pill-nav/

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/ExpandingPillNav.astro

Installing this one is more than pasting a section

  • installOne required file, plus supporting files worth taking:
    • src/components/ExpandingPillNav.astrocomponentrequired
    • src/components/ExpandingPillNav.mdreadmesupporting
    Use Copy header kit above to get them all, each one labelled with the path it belongs at.
---
/**
 * Expanding pill nav
 *
 * A floating glass navigation pill that sits over the page and widens once you
 * scroll away from the top. Below the wide breakpoint it spans the full width;
 * below the menu breakpoint the links collapse behind a toggle that opens them
 * inside the pill.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no images.
 * Every colour and measurement is a `--pn-*` custom property on the root.
 */

export interface NavLink {
	label: string;
	href: string;
	/** Marks this one as the page you are on. */
	current?: boolean;
}

interface Props {
	/** Accessible name of the brand link. Also its visually hidden text.
	 *  Omitted, the link is named "Home". */
	brandLabel?: string;
	/** Where the brand mark links to. */
	brandHref?: string;
	/** The navigation links. */
	links?: NavLink[];
	/** Optional call to action at the end of the list. */
	ctaLabel?: string;
	/** Destination for the call to action. Without one it is not a link. */
	ctaHref?: string;
	/** Accessible name for the `<nav>`, for pages with more than one. */
	navLabel?: string;
	/** Width the rail settles at before you scroll, once there is room for it. */
	restWidth?: string;
	/** Width it grows to after scrolling past the threshold. */
	scrolledWidth?: string;
	/** Pixels of scroll before the pill widens. */
	scrollThreshold?: number;
	/** How long the width change takes. */
	growMs?: number;
	/** Distance from the top of the viewport on small screens. */
	topOffset?: string;
	/** Distance from the top once the wide layout applies. */
	topOffsetWide?: string;
	/** Horizontal inset from the viewport edges. */
	inset?: string;
	/** Pill fill. Translucent by design — the blur behind it is the point. */
	surface?: string;
	/** Hairline around the pill. */
	border?: string;
	/** Backdrop blur behind the pill. `none` turns the glass off. */
	blur?: string;
	/** Link and brand colour. */
	ink?: string;
	/** Link hover colour, focus ring, and the call to action's fill. */
	accent?: string;
	/** Text colour on the call to action. */
	accentInk?: string;
	/** Corner radius below the wide breakpoint. */
	radius?: string;
	/** Corner radius once the wide layout applies. */
	radiusWide?: string;
	/** Stacking order. It floats over the page, so it has to win. */
	zIndex?: number;
	/** Added to the root element. */
	class?: string;
	/** Root element id. Also seeds the menu id. */
	id?: string;
}

const {
	// No invented brand, routes or call to action: the site supplies its own.
	brandLabel = "",
	brandHref = "/",
	links = [],
	ctaLabel = "",
	ctaHref,
	navLabel = "Primary",
	restWidth = "576px",
	scrolledWidth = "672px",
	scrollThreshold = 64,
	growMs = 1000,
	topOffset = "8px",
	topOffsetWide = "32px",
	inset = "32px",
	surface = "rgb(26 26 31 / 60%)",
	border = "#3d3947",
	blur = "24px",
	ink = "#ffffff",
	accent = "#9f9bf5",
	accentInk = "#14121f",
	radius = "12px",
	radiusWide = "16px",
	zIndex = 50,
	class: className,
	id,
} = Astro.props;

// One id per instance, so several navs can share a page without their toggles
// pointing at each other's menus.
const uid = id ?? `pn-${Math.random().toString(36).slice(2, 9)}`;
const menuId = `${uid}-menu`;

// The menu (and the toggle that opens it) only exists when there is something
// to put in it; with no brand either, there is no header to draw at all.
const hasMenu = links.length > 0 || Boolean(ctaLabel);
const hasContent = hasMenu || Boolean(brandLabel);

const style = [
	`--pn-rest:${restWidth}`,
	`--pn-scrolled:${scrolledWidth}`,
	`--pn-grow:${growMs}ms`,
	`--pn-top:${topOffset}`,
	`--pn-top-wide:${topOffsetWide}`,
	`--pn-inset:${inset}`,
	`--pn-surface:${surface}`,
	`--pn-border:${border}`,
	`--pn-blur:${blur}`,
	`--pn-ink:${ink}`,
	`--pn-accent:${accent}`,
	`--pn-accent-ink:${accentInk}`,
	`--pn-radius:${radius}`,
	`--pn-radius-wide:${radiusWide}`,
	`--pn-z:${zIndex}`,
].join(";");
---

{hasContent && (
<header
	class:list={["nav", className]}
	id={id}
	style={style}
	data-header-root
	data-pn-root
	data-pn-threshold={scrollThreshold}
>
	{/* The rail is what animates its width. Keeping it separate from the pill
	    means the pill never has to know how wide it is — it just fills. */}
	<div class="rail" data-pn-rail>
		<div class="pill">
			<div class="head">
				<a class="brand" href={brandHref}>
					<span class="sr-only">{brandLabel || "Home"}</span>
					{/* Original mark: two offset rings and a dot. No wordmark, so
					    nothing to replace when this lands in another project. */}
					<svg class="mark" viewBox="0 0 32 32" aria-hidden="true" focusable="false">
						<circle cx="16" cy="16" r="12"></circle>
						<circle cx="21" cy="13" r="7"></circle>
						<circle class="mark-dot" cx="12" cy="20" r="2.75"></circle>
					</svg>
				</a>

				{hasMenu && (
				<button
					type="button"
					class="toggle"
					data-pn-toggle
					aria-expanded="false"
					aria-controls={menuId}
				>
					<span class="sr-only" data-pn-toggle-label>Open menu</span>
					<svg class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
						<line class="bar bar-a" x1="4" y1="8" x2="20" y2="8"></line>
						<line class="bar bar-b" x1="4" y1="16" x2="20" y2="16"></line>
					</svg>
				</button>
				)}
			</div>

			{hasMenu && (
			<nav class="menu" id={menuId} data-pn-menu aria-label={navLabel}>
				<ul class="list">
					{
						links.map((link) => (
							<li>
								<a
									class="link"
									href={link.href}
									aria-current={link.current ? "page" : null}
								>
									{link.label}
								</a>
							</li>
						))
					}
					{
						ctaLabel && (
							<li class="cta-item">
								{ctaHref ? (
									<a class="cta" href={ctaHref}>
										{ctaLabel}
									</a>
								) : (
									/* No destination, so no link: a dead anchor that looks
									   clickable is worse than plain text. */
									<span class="cta is-static">{ctaLabel}</span>
								)}
							</li>
						)
					}
				</ul>
			</nav>
			)}
		</div>
	</div>
</header>
)}

<style>
	.nav {
		position: fixed;
		inset-inline: 0;
		top: var(--pn-top);
		z-index: var(--pn-z);
		box-sizing: border-box;
		padding-inline: var(--pn-inset);
		font-family:
			system-ui,
			-apple-system,
			"Segoe UI",
			Roboto,
			sans-serif;
		font-size: 16px;
		line-height: 1.5;
	}

	/* Set rather than inherited: a host stylesheet must not be able to change
	   the pill's height or push a link onto a second row. */
	.nav *,
	.nav *::before,
	.nav *::after {
		box-sizing: border-box;
	}

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

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		margin: -1px;
		padding: 0;
		overflow: hidden;
		clip-path: inset(50%);
		white-space: nowrap;
	}

	/* --- the rail: full width until there is room to settle -------------- */

	.rail {
		max-width: none;
		margin-inline: auto;
		transition: max-width var(--pn-grow) cubic-bezier(0.4, 0, 0.2, 1);
	}

	/* --- the pill --------------------------------------------------------- */

	.pill {
		position: relative;
		display: flex;
		flex-direction: column;
		width: 100%;
		padding: 8px;
		border: 1px solid var(--pn-border);
		border-radius: var(--pn-radius);
		background: var(--pn-surface);
		-webkit-backdrop-filter: blur(var(--pn-blur));
		backdrop-filter: blur(var(--pn-blur));
		/* The reference stacks five shadows at the same colour and doubling
		   radii, which is what keeps the edge crisp while the spread is soft. */
		box-shadow:
			0 4px 4px rgb(0 0 0 / 15%),
			0 8px 8px rgb(0 0 0 / 15%),
			0 16px 16px rgb(0 0 0 / 15%),
			0 32px 32px rgb(0 0 0 / 15%),
			0 64px 64px rgb(0 0 0 / 15%);
	}

	.head {
		display: flex;
		flex-direction: row;
		align-items: center;
		justify-content: space-between;
	}

	.brand {
		display: block;
		flex: none;
		width: 32px;
		height: 32px;
		border-radius: 8px;
		color: var(--pn-ink);
	}

	.mark {
		display: block;
		width: 32px;
		height: 32px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.75;
	}

	.mark-dot {
		fill: var(--pn-accent);
		stroke: none;
	}

	/* --- toggle ----------------------------------------------------------- */

	.toggle {
		position: relative;
		display: flex;
		align-items: center;
		justify-content: center;
		width: 36px;
		height: 36px;
		padding: 0;
		border: 1px solid var(--pn-border);
		border-radius: 8px;
		background: rgb(255 255 255 / 4%);
		color: var(--pn-ink);
		font: inherit;
		cursor: pointer;
	}

	/*
	 * The drawn control is 36px, which is the design and stays the design. The
	 * tappable area is not: 36 is under the 44px minimum, and this is the only
	 * control on the layout where the whole menu lives behind it.
	 *
	 * An invisible extension rather than a bigger button, so the pill's
	 * proportions are untouched and nothing moves. Inset -4px on each side
	 * takes 36 to 44 exactly.
	 */
	.toggle::after {
		content: "";
		position: absolute;
		inset: -4px;
	}

	.toggle:hover {
		background: rgb(255 255 255 / 10%);
	}

	.bars {
		width: 20px;
		height: 20px;
		fill: none;
		stroke: currentColor;
		stroke-width: 1.75;
		stroke-linecap: round;
	}

	.bar {
		transition:
			transform 200ms ease,
			opacity 200ms ease;
		transform-origin: center;
	}

	/* Two bars crossing into an X, so the control shows its own state rather
	   than relying on the menu below it to say so. */
	[data-pn-open] .bar-a {
		transform: translateY(4px) rotate(45deg);
	}

	[data-pn-open] .bar-b {
		transform: translateY(-4px) rotate(-45deg);
	}

	/* --- menu ------------------------------------------------------------- */

	.menu {
		display: flex;
		flex-direction: column;
		flex-grow: 1;
		justify-content: center;
		padding-block: 24px;
	}

	.list {
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		gap: 24px;
		text-align: center;
	}

	.link {
		display: inline-block;
		padding: 2px 4px;
		border-radius: 4px;
		color: var(--pn-ink);
		font-size: 0.875rem;
		text-decoration: none;
		transition: color 160ms ease;
	}

	.link:hover {
		color: var(--pn-accent);
	}

	.link[aria-current="page"] {
		color: var(--pn-accent);
	}

	.cta-item {
		display: flex;
		justify-content: center;
	}

	.cta {
		display: inline-flex;
		align-items: center;
		justify-content: center;
		padding: 8px 24px;
		border-radius: 8px;
		background: var(--pn-accent);
		color: var(--pn-accent-ink);
		font-size: 0.875rem;
		font-weight: 500;
		text-decoration: none;
		white-space: nowrap;
		transition: filter 160ms ease;
	}

	.cta:hover {
		filter: brightness(1.08);
	}

	.cta.is-static {
		background: rgb(255 255 255 / 10%);
		color: var(--pn-ink);
		cursor: default;
	}

	/* One ring for every control. The reference leaves the browser default,
	   which on a dark glass surface is close to invisible. */
	.brand:focus-visible,
	.toggle:focus-visible,
	.link:focus-visible,
	.cta:focus-visible {
		outline: 2px solid var(--pn-accent);
		outline-offset: 3px;
	}

	/* --- collapsed, once the script is running ---------------------------- */

	/*
	 * The menu is open markup by default, so with no JavaScript every link is
	 * reachable. The script adds `data-pn-js`, and only then does the menu
	 * collapse behind the toggle below the menu breakpoint.
	 */
	.toggle {
		display: none;
	}

	@media (max-width: 767.98px) {
		[data-pn-js] .toggle {
			display: flex;
		}

		[data-pn-js] .menu {
			display: none;
		}

		[data-pn-js][data-pn-open] .menu {
			display: flex;
		}
	}

	/* --- menu breakpoint: links inline ------------------------------------ */

	@media (min-width: 768px) {
		.pill {
			flex-direction: row;
			align-items: center;
			justify-content: space-between;
		}

		.menu {
			padding-block: 0;
		}

		.list {
			flex-direction: row;
			gap: 12px;
		}

		.link {
			padding: 2px 6px;
		}
	}

	/* --- wide breakpoint: the rail settles and starts growing -------------- */

	@media (min-width: 1024px) {
		.nav {
			top: var(--pn-top-wide);
		}

		.pill {
			border-radius: var(--pn-radius-wide);
		}

		.rail {
			max-width: var(--pn-rest);
		}

		[data-pn-scrolled] .rail {
			max-width: var(--pn-scrolled);
		}
	}

	/* --- reduced motion ---------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		.rail,
		.bar,
		.link,
		.cta {
			transition-duration: 0.01ms;
		}
	}
</style>

<script>
	/**
	 * One controller per instance, and one boot per page view.
	 *
	 * Three things this has to survive that a section never does. It is
	 * installed once into a shared layout, so a destination project with
	 * `<ClientRouter />` swaps the document underneath it and the module never
	 * re-evaluates: initialisation therefore hangs off `astro:page-load` as
	 * well as first load. It can be booted more than once, so init is
	 * idempotent. And its listeners outlive the element unless something
	 * removes them, so every one of them is bound to an AbortController that
	 * is aborted before the swap.
	 */
	function initExpandingPillNav(root: HTMLElement) {
		// Idempotent: a second boot over the same element is a no-op rather
		// than a second set of listeners on the same button.
		if (root.dataset.pnReady === "") return;

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

		root.dataset.pnReady = "";

		// Turning this on is what enables the collapsed layout at all, so the
		// fully expanded menu is what renders if this script never runs.
		root.setAttribute("data-pn-js", "");

		/* Every listener below carries this signal, so one abort() detaches the
		   lot — including the ones on window and document, which would
		   otherwise pile up one set per client-side navigation. */
		const controller = new AbortController();
		const { signal } = controller;

		const threshold = Number(root.dataset.pnThreshold) || 64;
		let open = false;
		let frame = 0;

		function setOpen(next: boolean) {
			open = next;
			toggle!.setAttribute("aria-expanded", String(next));
			if (label) label.textContent = next ? "Close menu" : "Open menu";
			if (next) root.setAttribute("data-pn-open", "");
			else root.removeAttribute("data-pn-open");
		}

		toggle.addEventListener("click", () => setOpen(!open), { signal });

		/*
		 * Escape closes it and puts focus back on the control that opened it,
		 * rather than leaving focus inside a menu that is no longer on screen.
		 *
		 * Bound to the document rather than to the root. Scoped to the root it
		 * only fired while focus was already inside the header, so Escape did
		 * nothing whenever the menu had been opened by a tap — or, in a project
		 * with a client-side router, on the page after a navigation, where
		 * focus starts on the body. The menu covers content while it is open,
		 * so Escape has to work wherever focus happens to be.
		 *
		 * Still safe with several navs on a page: the handler returns unless
		 * *this* instance is the one that is open.
		 */
		document.addEventListener(
			"keydown",
			(event) => {
				if (event.key !== "Escape" || !open) return;
				event.preventDefault();
				setOpen(false);
				toggle.focus();
			},
			{ signal },
		);

		// A link inside the collapsed menu has been followed, so close it.
		menu.addEventListener(
			"click",
			(event) => {
				if (open && (event.target as HTMLElement).closest("a")) setOpen(false);
			},
			{ signal },
		);

		/* Tapping the page behind an open menu should close it. Without this the
		   menu stays over the content the tap was aimed at, which on a phone is
		   the whole screen. */
		document.addEventListener(
			"pointerdown",
			(event) => {
				if (!open || root.contains(event.target as Node)) return;
				setOpen(false);
			},
			{ signal },
		);

		/*
		 * Past the threshold the rail is allowed to grow. The class does the
		 * moving: the only job here is deciding which side of the line we are
		 * on, and doing it once per frame rather than once per scroll event.
		 */
		function measure() {
			const past = window.scrollY > threshold;
			if (past === root.hasAttribute("data-pn-scrolled")) return;
			if (past) root.setAttribute("data-pn-scrolled", "");
			else root.removeAttribute("data-pn-scrolled");
		}

		function schedule() {
			if (frame) return;
			frame = requestAnimationFrame(() => {
				frame = 0;
				measure();
			});
		}

		window.addEventListener("scroll", schedule, { passive: true, signal });
		measure();

		/* Astro replaces the document on a client-side navigation. Detach first,
		   and drop the ready flag so a re-used element can be booted again. */
		document.addEventListener(
			"astro:before-swap",
			() => {
				if (frame) cancelAnimationFrame(frame);
				setOpen(false);
				controller.abort();
				delete root.dataset.pnReady;
			},
			{ once: true, signal },
		);
	}

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

	/* Astro's own <script> is a deferred module, so the DOM is already parsed
	   by the time this runs. The readyState check is for the case where this
	   file is copied into a project that inlines it some other way. */
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", boot, { once: true });
	} else {
		boot();
	}

	/* Fires on every view transition in a project using <ClientRouter />, and
	   never at all in one that does not. Registered once per module, so it
	   cannot accumulate. */
	document.addEventListener("astro:page-load", boot);
</script>