Component librarylocal catalog
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.

Start here

Component reference

Paste it into 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 the branding and content open.

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.

ChatGPT brief

Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.

Read it first
Help me add this component to one of my Astro websites:

Component: Expanding pill nav
Library page: https://astro.baysixmedia.com/components/expanding-pill-nav

I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]

Place it:
[ENTER LOCATION OR SELECTOR]

Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]

Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.

Placement prompt

Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.

Read it first
Add Expanding pill nav from my Astro Component Library to this Astro project.

Library page:
https://astro.baysixmedia.com/components/expanding-pill-nav

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.

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

Position:
[ENTER WHERE IT SHOULD APPEAR]

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.

src/components/library/ExpandingPillNav.astro

one fileNo imports, no companion files, no packages. Copy ExpandingPillNav.astro into any Astro project's components folder and render it.

---
/**
 * 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. */
	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 {
	brandLabel = "Northbeam home",
	brandHref = "/",
	links = [
		{ label: "Work", href: "/work" },
		{ label: "Services", href: "/services" },
		{ label: "Journal", href: "/journal" },
		{ label: "Studio", href: "/studio" },
		{ label: "Contact", href: "/contact" },
	],
	ctaLabel = "Start a project",
	ctaHref = "/start",
	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`;

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(";");
---

<div
	class:list={["nav", className]}
	id={id}
	style={style}
	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}</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>

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

			<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>
</div>

<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 {
		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;
	}

	.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. Every element is looked up inside its own
	 * root, so several navs can share a page without touching each other.
	 */
	function initExpandingPillNav(root: HTMLElement) {
		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;

		// 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", "");

		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));

		// 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.
		root.addEventListener("keydown", (event) => {
			if (event.key !== "Escape" || !open) return;
			event.preventDefault();
			setOpen(false);
			toggle.focus();
		});

		// 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);
		});

		/*
		 * 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 });
		measure();
	}

	document
		.querySelectorAll<HTMLElement>("[data-pn-root]")
		.forEach(initExpandingPillNav);
</script>