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 "Floating panel 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: Floating panel header
Library page: https://astro.baysixmedia.com/components/floating-panel-header/
Navigation mode: dropdown — single-column panels that open from their triggers
Position mode: fixed
Desktop dropdowns: yes
Separate mobile menu: yes
Locks body scroll while open: yes
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
Prop interface, verbatim from the component:
interface Props {
/** Wordmark text beside the brand mark. `""` renders the mark alone. */
brandName?: string;
/** Where the brand links to. Unsafe or omitted renders a non-linked wordmark. */
brandHref?: string;
/** Accessible name for the `<nav>` landmark. */
navLabel?: string;
/** Accessible name for the small-screen menu button. */
menuLabel?: string;
/** The navigation items. */
items?: NavItem[];
/** Secondary action at the right. `""` hides it. */
loginLabel?: string;
loginHref?: string;
/** Primary action at the right. `""` hides it. */
ctaLabel?: string;
ctaHref?: string;
/** Marks the matching link `aria-current="page"`. Compared after trimming. */
currentPath?: string;
/** Pixels of scroll before the bar narrows. The reference uses 50. */
scrollThreshold?: number;
/** Width at or below which the sheet replaces the inline navigation. */
breakpoint?: number;
/** Distance from the top of the viewport. */
topOffset?: string;
/** Width of the bar at rest. */
maxWidth?: string;
/** Width of the bar once narrowed. */
scrolledMaxWidth?: string;
/** Bar fill once narrowed. Mixed to 75% so the blur behind it reads. */
surface?: string;
/** Panel and card fill. */
panelBackground?: string;
/** Primary text. */
ink?: string;
/** Navigation labels, descriptions and headings. */
muted?: string;
/** Panel footer link, and the focus ring. */
accent?: string;
/** Backdrop blur for the narrowed bar and the sheet. `0px` turns it off. */
blur?: 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 panel and sheet ids. */
id?: string;
}
Route-data example (src/components/FloatingPanelHeader.nav-data.ts) — replace every route in it with real ones from this project:
/**
* FloatingPanelHeader — navigation data, worked example.
*
* Optional. The component ships a full demo tree as its own default, so it
* renders without this file. What this is for is the thing that actually goes
* wrong when a header is installed somewhere real: the destination project has
* its own routes, and someone has to map them into this shape. Doing that in a
* data file rather than inline in a layout means the routes can be read,
* reviewed and changed without touching markup.
*
* Copy it next to the component, edit every label and href to match the
* destination's real routes, and pass it in:
*
* ---
* import Header from "../components/FloatingPanelHeader.astro";
* import { primaryNav } from "../components/FloatingPanelHeader.nav-data";
* ---
* <Header items={primaryNav} currentPath={Astro.url.pathname} />
*
* The types come from the component itself, so this file cannot drift from
* what the component accepts: rename a field there and `astro check` fails
* here.
*/
import type { NavItem } from "./FloatingPanelHeader.astro";
/**
* Every route in the example tree is a placeholder. Replace them with routes
* that exist in the destination project — a header pointing at four 404s is
* worse than no header, because it looks finished.
*
* Shape rules the component enforces:
* - an item has `href` OR `groups`, never both;
* - an item with `groups` renders as a dropdown trigger (a real `<button>`);
* - an item with `href` renders as a plain link;
* - a group whose links carry `description` is drawn as the raised card
* column with icon tiles; a group whose links do not is drawn as the
* compact list column beside it. That is the only thing that decides it,
* so mixing both inside one group gives you card rows with a blank second
* line;
* - `footer` renders a closing link under the columns, and is ignored on an
* item with no `groups`;
* - `icon` names one of the built-in icons — bolt, chart, shield, spark,
* compass, gem, code, chat, book, layers, gauge, users, lock, store, dot.
* An unknown name falls back to `dot` rather than an empty tile.
*/
export const primaryNav: NavItem[] = [
{
label: "Product",
groups: [
{
heading: "Capabilities",
links: [
{
label: "Overview",
href: "/product",
description: "What it does, in one page",
icon: "compass",
},
{
label: "Integrations",
href: "/product/integrations",
description: "What it talks to",
icon: "layers",
},
{
label: "Security",
href: "/product/security",
description: "How access is handled",
icon: "lock",
},
],
},
],
footer: { label: "See the whole product", href: "/product" },
},
{
label: "Resources",
groups: [
{
heading: "Learn",
links: [
{
label: "Guides",
href: "/guides",
description: "Short, practical walkthroughs",
icon: "book",
},
{
label: "Changelog",
href: "/changelog",
description: "What shipped, and when",
icon: "gauge",
},
],
},
{
/* No descriptions in this group, so it renders as the compact
list column beside the card one. */
heading: "Community",
links: [
{ label: "Forum", href: "/community", icon: "users" },
{ label: "Open source", href: "/open-source", icon: "code" },
],
},
],
},
/* Plain destinations: no `groups`, so no panel and no trigger button. */
{ label: "Pricing", href: "/pricing" },
{ label: "Contact", href: "/contact" },
];
Known integration notes for this header:
- Fixed, so it is out of the flow: the page below it needs top padding of at least topOffset plus the bar height, or the first heading renders underneath it. Hash targets need scroll-margin-top for the same reason.
- The bar is transparent until the page scrolls past scrollThreshold, so at the top of a route it takes its legibility from whatever is behind it. Over a dark hero, set ink and muted to light values, or give the page a light band at the top.
- Panels are centred under their trigger and capped at 560px, so they stay inside the viewport at the 1024px breakpoint. A layout that clips horizontal overflow above the header will still clip them, because they escape the bar's bottom edge.
- backdrop-filter is used for the glass on the narrowed bar and the open sheet. Where it is unsupported both still get their fill, ring and shadow; only the blur is missing.
- The mobile sheet is the bar itself expanded over a full-screen ground, and it locks body scroll while open. Anything else on the page that writes to document.body.style.overflow will fight it.
- Pass currentPath={Astro.url.pathname} to get aria-current on the matching link; it is not inferred.
- The demo tree ships two panels, seven panel links and two plain links. A destination with three routes should pass a smaller items array rather than padding it out.
- The brand mark goes in the brand-mark named slot as an imported image or inline SVG. There is no prop that takes raw markup, and the component never uses set:html.
--- 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, and the page behind it stops scrolling while it is open; 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 is position: fixed, so it is out of the flow entirely. The page below it must reserve its height, or the first heading renders underneath it. Check the reserved height at every breakpoint, because the header's own height changes with them.
11. Reserve the header's height on the page content so nothing starts underneath it, and re-check the reserved value at every breakpoint.
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 fixed 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/FloatingPanelHeader.astro =====
---
/**
* FloatingPanelHeader — a fixed header that floats as a rounded bar over the
* page, narrows into frosted glass once you scroll, opens dropdown panels of
* icon rows from its navigation, and collapses into a full-screen sheet below
* the desktop breakpoint.
*
* One self-contained file. No imports, no companion files required, no npm
* packages, no global stylesheet, no webfonts, no images, no network requests.
* Static-only — no SSR, no endpoints, no cookies, no env.
*
* Design behaviour is inspired by the public Tailark Quartz "header five"
* preview (https://pro.tailark.com/preview/quartz/header/five), observed in a
* browser at several widths and scroll positions and rebuilt from those
* measurements. No source, markup, CSS, script, branding, logo or artwork from
* that page is used here, and the demo content is original. The reference is a
* React/Tailwind block built on a headless menu library; this is one Astro file
* with scoped CSS and about 200 lines of dependency-free TypeScript. See
* FloatingPanelHeader.md.
*
* The scroll morph, measured at 1440px and reproduced as CSS transitions
* rather than scripted per-frame styles:
*
* at rest 1152px wide, transparent, no ring, no shadow, no blur
* narrowed 896px wide, surface at 75%, hairline ring, soft shadow,
* backdrop blur
* trigger scrollY > 50 (configurable), reversible at the same point
* transition 500ms cubic-bezier(0.4, 0, 0.2, 1) on every property
*
* The bar's height never changes and it is `position: fixed`, so nothing in
* the page moves at any point in the morph.
*
* One set of markup serves both layouts. A panel is an absolutely positioned
* popup under its trigger at desktop widths and an in-flow accordion inside
* the sheet below the breakpoint — the same button, the same `aria-expanded`,
* the same links. Nothing is duplicated, so there is never a second copy of a
* link hiding in the tab order.
*
* At and below `breakpoint` the morph is switched off and the header becomes a
* bar with a menu button over a full-screen sheet. The breakpoint drives the
* controller, the CSS layout and the no-script fallback alike; none of the
* three is hardcoded.
*
* With no JavaScript there is no menu button — it could not open anything —
* and the navigation is shown in place instead, with panels opening on hover
* and focus so every link stays reachable by keyboard.
*
* Several instances can share a page: every query is scoped to the instance
* root, ids are minted per instance, and the body scroll lock is counted at
* module scope so two headers cannot release each other's.
*/
/** One row inside a dropdown panel. */
export interface PanelLink {
/** Visible text. A row without one is dropped. */
label: string;
/** Destination. Unsafe or missing values render as plain text, never a link. */
href: string;
/**
* Second line under the label. Rows that have one are drawn as cards with
* a raised icon tile; rows that do not are drawn as a compact list.
*/
description?: string;
/** Name from the built-in icon set. Unknown names fall back to a dot. */
icon?: string;
/** Opens in a new tab, with the matching rel. */
external?: boolean;
}
/** A column inside a dropdown panel. */
export interface PanelGroup {
/** Small uppercase heading above the column. `""` renders no heading. */
heading?: string;
links: PanelLink[];
}
/** A link at the foot of a panel, under the columns. */
export interface PanelFooterLink {
label: string;
href: string;
}
export interface NavItem {
label: string;
/** A plain destination. Give this OR `groups`, not both. */
href?: string;
/** Turns the item into a dropdown trigger. */
groups?: PanelGroup[];
/** Optional closing link under the panel's columns. Ignored without `groups`. */
footer?: PanelFooterLink;
}
interface Props {
/** Wordmark text beside the brand mark. `""` renders the mark alone. */
brandName?: string;
/** Where the brand links to. Unsafe or omitted renders a non-linked wordmark. */
brandHref?: string;
/** Accessible name for the `<nav>` landmark. */
navLabel?: string;
/** Accessible name for the small-screen menu button. */
menuLabel?: string;
/** The navigation items. */
items?: NavItem[];
/** Secondary action at the right. `""` hides it. */
loginLabel?: string;
loginHref?: string;
/** Primary action at the right. `""` hides it. */
ctaLabel?: string;
ctaHref?: string;
/** Marks the matching link `aria-current="page"`. Compared after trimming. */
currentPath?: string;
/** Pixels of scroll before the bar narrows. The reference uses 50. */
scrollThreshold?: number;
/** Width at or below which the sheet replaces the inline navigation. */
breakpoint?: number;
/** Distance from the top of the viewport. */
topOffset?: string;
/** Width of the bar at rest. */
maxWidth?: string;
/** Width of the bar once narrowed. */
scrolledMaxWidth?: string;
/** Bar fill once narrowed. Mixed to 75% so the blur behind it reads. */
surface?: string;
/** Panel and card fill. */
panelBackground?: string;
/** Primary text. */
ink?: string;
/** Navigation labels, descriptions and headings. */
muted?: string;
/** Panel footer link, and the focus ring. */
accent?: string;
/** Backdrop blur for the narrowed bar and the sheet. `0px` turns it off. */
blur?: 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 panel and sheet ids. */
id?: string;
}
const {
brandName = "Fieldline",
brandHref = "/",
navLabel = "Primary",
menuLabel = "Menu",
items = [
{
label: "Product",
groups: [
{
heading: "Capabilities",
links: [
{
label: "Automations",
href: "/product/automations",
description: "Rules that run without you",
icon: "bolt",
},
{
label: "Insights",
href: "/product/insights",
description: "Numbers you can act on",
icon: "chart",
},
{
label: "Guardrails",
href: "/product/guardrails",
description: "Approvals and an audit trail",
icon: "shield",
},
{
label: "Assist",
href: "/product/assist",
description: "Drafting help in every field",
icon: "spark",
},
],
},
],
footer: { label: "See the whole product", href: "/product" },
},
{
label: "Solutions",
groups: [
{
heading: "By team",
links: [
{
label: "Operations",
href: "/solutions/operations",
description: "Plan the week in one view",
icon: "compass",
},
{
label: "Finance",
href: "/solutions/finance",
description: "Close the month on time",
icon: "gem",
},
{
label: "Engineering",
href: "/solutions/engineering",
description: "Ship without the paperwork",
icon: "code",
},
{
label: "Support",
href: "/solutions/support",
description: "Answer with the full history",
icon: "chat",
},
],
},
{
heading: "Library",
links: [
{ label: "Case studies", href: "/library/case-studies", icon: "book" },
{ label: "Templates", href: "/library/templates", icon: "layers" },
{ label: "Changelog", href: "/changelog", icon: "gauge" },
],
},
],
},
{ label: "Pricing", href: "/pricing" },
{ label: "Company", href: "/company" },
],
loginLabel = "Sign in",
loginHref = "/signin",
ctaLabel = "Talk to sales",
ctaHref = "/contact",
currentPath,
scrollThreshold = 50,
breakpoint = 1024,
topOffset = "12px",
maxWidth = "1152px",
scrolledMaxWidth = "896px",
surface = "#fafafa",
panelBackground = "#ffffff",
ink = "#09090b",
muted = "#52525b",
accent = "#2c64ff",
blur = "8px",
zIndex = 50,
class: className,
id,
}: Props = Astro.props;
/*
* Link data is treated as untrusted. It can arrive from a CMS, a config file
* or a loop over content entries, and a header is the one component on the
* page that every route renders — so a bad href here is a bad href everywhere.
*
* Anything that is not a plain relative path, fragment, or http/https/mailto/
* tel URL is refused. `javascript:` and `data:` are the ones that matter;
* everything unrecognised is refused too, rather than allow-listed by
* accident. A refused link renders as plain text, which is visible and
* harmless, instead of silently vanishing.
*/
const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
function safeHref(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const href = value.trim();
if (href === "") return undefined;
/* Strip control characters before testing: "java\tscript:" is a scheme too. */
const bare = [...href]
.filter((ch) => {
const code = ch.codePointAt(0) ?? 0;
return code > 31 && code !== 127;
})
.join("")
.trim();
if (
bare.startsWith("/") ||
bare.startsWith("#") ||
bare.startsWith("./") ||
bare.startsWith("../")
) {
return bare;
}
if (SAFE_SCHEME.test(bare)) return bare;
/* A bare relative path like "about/" — no colon before the first slash. */
const colon = bare.indexOf(":");
const slash = bare.indexOf("/");
if (colon === -1 || (slash !== -1 && slash < colon)) return bare;
return undefined;
}
/** Trailing slashes differ between routers; compare without them. */
const normalisePath = (value: string) => {
const trimmed = value.trim();
if (trimmed === "" || trimmed === "/") return "/";
return trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
};
const current = typeof currentPath === "string" ? normalisePath(currentPath) : undefined;
const isCurrent = (href?: string) =>
href !== undefined && current !== undefined && normalisePath(href) === current;
/*
* The icon set.
*
* Drawn here rather than imported, so the component stays one file with no
* package to install, and so a caller names an icon with a string instead of
* passing markup: there is no prop that takes HTML and the component never
* uses `set:html`. An unknown name falls back to a neutral dot rather than
* rendering an empty tile.
*/
const ICONS: Record<string, string[]> = {
bolt: ["M13.2 3.5 6 13h4.6l-.8 7.5L18 11h-4.6z"],
chart: ["M4.8 19.4h14.4", "M8.2 16.4v-5.2", "M12 16.4V6.6", "M15.8 16.4V9.8"],
shield: ["M12 3.6 5.8 6.1v5.2c0 4 2.6 7.1 6.2 8.1 3.6-1 6.2-4.1 6.2-8.1V6.1z", "m9.3 12.1 2 2 3.5-3.9"],
spark: [
"M12 4.2l1.5 4.3 4.3 1.5-4.3 1.5L12 15.8l-1.5-4.3L6.2 10l4.3-1.5z",
"M18.2 14.6l.6 1.6 1.6.6-1.6.6-.6 1.6-.6-1.6-1.6-.6 1.6-.6z",
],
compass: [
"M12 4.4a7.6 7.6 0 1 0 0 15.2 7.6 7.6 0 0 0 0-15.2z",
"m14.8 9.2-1.6 4.2-4.2 1.6 1.6-4.2z",
],
gem: ["m12 20.2-8-9.6 2.6-4.6h10.8L20 10.6z", "M4.2 10.6h15.6", "m9.2 6 2.8 14.2L14.8 6"],
code: ["m9.2 8.8-3.6 3.4 3.6 3.4", "m14.8 8.8 3.6 3.4-3.6 3.4"],
chat: [
"M20 12.2c0 3.4-3.6 6.2-8 6.2-.9 0-1.8-.1-2.6-.4L5 19.5l1.2-3.1C4.8 15.3 4 13.8 4 12.2 4 8.8 7.6 6 12 6s8 2.8 8 6.2z",
],
book: ["M5.2 5.6A1.6 1.6 0 0 1 6.8 4h11v13.4h-11a1.6 1.6 0 0 0-1.6 1.6z", "M17.8 17.4V20H6.8"],
layers: ["m12 4.4 7.4 3.8-7.4 3.8-7.4-3.8z", "m5.2 12.4 6.8 3.5 6.8-3.5", "m5.2 16.2 6.8 3.5 6.8-3.5"],
gauge: ["M4.6 17.4a7.6 7.6 0 1 1 14.8 0", "m12 14 3.2-3.4"],
users: [
"M9.4 11.4a3.2 3.2 0 1 0 0-6.4 3.2 3.2 0 0 0 0 6.4z",
"M3.8 19.4c0-3 2.5-5.4 5.6-5.4s5.6 2.4 5.6 5.4",
"M16.2 5.4a3.2 3.2 0 0 1 0 6.2",
"M17.4 14.3c2 .6 3.4 2.4 3.4 4.6",
],
lock: [
"M7.4 10.4h9.2a1.4 1.4 0 0 1 1.4 1.4v6.4a1.4 1.4 0 0 1-1.4 1.4H7.4A1.4 1.4 0 0 1 6 18.2v-6.4a1.4 1.4 0 0 1 1.4-1.4z",
"M8.8 10.4V8.2a3.2 3.2 0 0 1 6.4 0v2.2",
],
store: [
"M4.6 9.6h14.8v8.9a1.5 1.5 0 0 1-1.5 1.5H6.1a1.5 1.5 0 0 1-1.5-1.5z",
"M4.2 9.6 5.9 5.2A1.5 1.5 0 0 1 7.3 4.3h9.4a1.5 1.5 0 0 1 1.4.9l1.7 4.4",
],
dot: [
"M12 4.6a7.4 7.4 0 1 0 0 14.8 7.4 7.4 0 0 0 0-14.8z",
"M12 9.4a2.6 2.6 0 1 0 0 5.2 2.6 2.6 0 0 0 0-5.2z",
],
};
const iconPaths = (name?: string) =>
(typeof name === "string" && ICONS[name]) || ICONS.dot;
/*
* Normalise the tree once, so the template renders data rather than deciding
* what is valid halfway down a nested map. A malformed entry is dropped rather
* than rendered as an empty target.
*/
function hasLabel<T>(value: T): value is T & { label: string } {
const label = (value as { label?: unknown } | null)?.label;
return typeof label === "string" && label.trim() !== "";
}
const nav = (Array.isArray(items) ? items : []).filter(hasLabel).map((item, index) => {
const groups = (Array.isArray(item.groups) ? item.groups : [])
.map((group) => ({
heading: typeof group?.heading === "string" ? group.heading.trim() : "",
links: (Array.isArray(group?.links) ? group.links : [])
.filter(hasLabel)
.map((link) => {
const href = safeHref(link.href);
const description =
typeof link.description === "string" ? link.description.trim() : "";
return {
label: link.label.trim(),
href,
description,
paths: iconPaths(link.icon),
external: href !== undefined && link.external === true,
isCurrent: isCurrent(href),
};
}),
}))
.filter((group) => group.links.length > 0);
const footerHref = safeHref(item.footer?.href);
const footerLabel =
typeof item.footer?.label === "string" ? item.footer.label.trim() : "";
return {
label: item.label.trim(),
href: safeHref(item.href),
groups,
/* A column of rows is a card when its rows carry descriptions, and a
compact list when they do not — the reference draws both. */
footer: footerHref && footerLabel ? { label: footerLabel, href: footerHref } : undefined,
index,
};
});
const homeHref = safeHref(brandHref);
const brandLabel = typeof brandName === "string" ? brandName.trim() : "";
const brandInitial = brandLabel.charAt(0) || "•";
const login = {
label: typeof loginLabel === "string" ? loginLabel.trim() : "",
href: safeHref(loginHref),
};
const cta = {
label: typeof ctaLabel === "string" ? ctaLabel.trim() : "",
href: safeHref(ctaHref),
};
/* Random rather than sequential: a module-level counter is shared by every
render in the same server process, which is fine for one page and wrong the
moment two pages are built in parallel and compared. */
const uid = id ?? `fp-${Math.random().toString(36).slice(2, 9)}`;
const menuId = `${uid}-menu`;
/*
* The no-script fallback needs a real media query at the configured
* breakpoint, and a media query cannot read a custom property. So one is
* emitted per instance instead, keyed to this instance's uid.
*
* Delivered with `set:text`, never a raw-HTML directive: a `<style>` element's
* text content is its stylesheet, so the safe text channel is also the correct
* one. This component uses no raw-HTML injection anywhere. Because `set:text`
* escapes HTML, and a `<style>`'s contents are raw text the parser does not
* decode, an escaped character would survive into the CSS as an entity and
* break the rule it sits in — so the string below is built from characters
* that escaping cannot touch: no quotes, no angle brackets, no ampersands.
*
* `uid` can come from the `id` prop, which is caller data going into a
* selector. It is reduced to `[A-Za-z0-9_-]` and forced to begin with a
* letter, which makes it a valid CSS identifier and lets the attribute
* selector go unquoted. `breakpoint` is already coerced to a positive number.
* Nothing else reaches the stylesheet.
*/
const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, "");
const cssUid = /^[A-Za-z]/.test(safeUid) ? safeUid : `fp-${safeUid || "instance"}`;
const bp = Math.max(0, Number(breakpoint) || 0) || 1024;
/*
* What the fallback has to achieve, and why each line is here:
*
* any width no menu button. Without the controller it cannot open or
* close anything, and a control that does nothing is worse
* than no control. The base rule already hides it; only
* `[data-fp-mobile]`, which the controller sets, brings it
* back.
* at or below bp the bar stacks and the navigation is shown in place under
* it, with panels in the flow rather than as popups. The
* bar scrolls itself if it is taller than the screen, and
* never touches the page's own scrolling — the scroll lock
* is controller-only.
*
* Panels stay closed until hovered or focused, which the scoped stylesheet
* handles for the no-script case at every width, so a keyboard alone still
* opens every one of them.
*/
const fallbackCss = `
@media (max-width:${bp}px){
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-row{display:flex;flex-direction:column;align-items:stretch;padding-block:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-head{min-height:56px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-menu{display:flex;flex-direction:column;width:100%;padding-bottom:12px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-list{flex-direction:column;align-items:stretch;gap:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-item{flex-direction:column;align-items:stretch}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-trigger,[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-link{width:100%;justify-content:space-between;min-height:52px;font-size:17px;border-radius:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-panel{position:static;transform:none;width:auto;max-width:none;margin-top:0;background:none;box-shadow:none;padding:0 0 8px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-columns{flex-direction:column;gap:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-column{background:none;box-shadow:none;padding:0}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-actions{width:100%;flex-direction:column;align-items:stretch;padding-top:12px}
[data-fp-uid=${cssUid}]:not([data-fp-ready]) .fp-bar{max-height:calc(100dvh - 2 * ${topOffset});overflow-y:auto}
}`;
const styleVars = [
`--fp-top:${topOffset}`,
`--fp-max:${maxWidth}`,
`--fp-max-scrolled:${scrolledMaxWidth}`,
`--fp-surface:${surface}`,
`--fp-panel:${panelBackground}`,
`--fp-ink:${ink}`,
`--fp-muted:${muted}`,
`--fp-accent:${accent}`,
`--fp-blur:${blur}`,
`--fp-z:${zIndex}`,
].join(";");
const Brand = homeHref ? "a" : "span";
---
<header
class:list={["fp-header", className]}
id={id}
data-header-root
data-fp-root
data-fp-uid={cssUid}
data-fp-threshold={String(Math.max(0, Number(scrollThreshold) || 0))}
data-fp-breakpoint={String(bp)}
style={styleVars}
>
{/*
The no-script fallback, at the configured breakpoint. Inline because a
media query cannot read a custom property, and scoped to this instance
by uid so two headers with different breakpoints do not fight. Every
rule is inert the moment the controller adds `data-fp-ready`.
*/}
<style is:inline set:text={fallbackCss}></style>
<div class="fp-bar" data-fp-bar>
<div class="fp-row">
<div class="fp-head">
<Brand
class="fp-brand"
href={homeHref}
aria-label={homeHref ? `${brandLabel} home` : undefined}
>
{/*
The mark is decorative: the word is inside the link and
stays in the accessibility tree, so the link keeps its
name whether or not a mark is supplied.
*/}
<span class="fp-mark" aria-hidden="true">
<slot name="brand-mark">
<span class="fp-mark-fallback">{brandInitial}</span>
</slot>
</span>
{brandLabel && <span class="fp-word">{brandLabel}</span>}
</Brand>
<button
class="fp-burger"
type="button"
data-fp-burger
aria-expanded="false"
aria-controls={menuId}
>
<span class="fp-sr">{menuLabel}</span>
<svg class="fp-burger-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path class="fp-burger-top" d="M4 7h16"></path>
<path class="fp-burger-mid" d="M4 12h16"></path>
<path class="fp-burger-bottom" d="M4 17h16"></path>
</svg>
</button>
</div>
{/*
Nav and actions live in one element so the menu button has a
single thing to control and the sheet has a single thing to
show. At desktop widths this element is `display: contents`, so
its two children become the middle and right cells of the bar's
own grid and no extra box sits between them.
*/}
<div class="fp-menu" id={menuId} data-fp-menu>
<nav class="fp-nav" aria-label={navLabel}>
<ul class="fp-list">
{
nav.map((item) => {
const panelId = `${uid}-panel-${item.index}`;
return item.groups.length > 0 ? (
<li class="fp-item" data-fp-item>
<button
class="fp-trigger"
type="button"
data-fp-trigger
aria-expanded="false"
aria-controls={panelId}
>
{item.label}
<svg
class="fp-chevron"
viewBox="0 0 16 16"
aria-hidden="true"
focusable="false"
>
<path d="M4 6.2 8 10.2l4-4"></path>
</svg>
</button>
<div class="fp-panel" id={panelId} data-fp-panel>
<div class="fp-columns">
{item.groups.map((group) => {
const cards = group.links.some((link) => link.description !== "");
return (
<div class:list={["fp-column", cards ? "is-cards" : "is-list"]}>
{group.heading && (
<p class="fp-heading">{group.heading}</p>
)}
<ul class="fp-cells">
{group.links.map((link) => {
const Cell = link.href ? "a" : "span";
return (
<li>
<Cell
class:list={["fp-cell", !link.href && "is-inert"]}
href={link.href}
aria-current={link.isCurrent ? "page" : undefined}
target={link.external ? "_blank" : undefined}
rel={link.external ? "noopener noreferrer" : undefined}
>
<span class="fp-tile" aria-hidden="true">
<svg
class="fp-icon"
viewBox="0 0 24 24"
focusable="false"
>
{link.paths.map((d) => (
<path d={d}></path>
))}
</svg>
</span>
<span class="fp-cell-text">
<span class="fp-cell-title">{link.label}</span>
{link.description && (
<span class="fp-cell-desc">
{link.description}
</span>
)}
</span>
</Cell>
</li>
);
})}
</ul>
</div>
);
})}
</div>
{item.footer && (
<p class="fp-panel-foot">
<a class="fp-panel-more" href={item.footer.href}>
{item.footer.label}
</a>
</p>
)}
</div>
</li>
) : (
<li class="fp-item">
{item.href ? (
<a
class="fp-link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : undefined}
>
{item.label}
</a>
) : (
<span class="fp-link is-inert">{item.label}</span>
)}
</li>
);
})
}
</ul>
</nav>
<div class="fp-actions">
{login.label && login.href && (
<a class="fp-ghost" href={login.href}>
{login.label}
</a>
)}
{cta.label && cta.href && (
<a class="fp-cta" href={cta.href}>
{cta.label}
</a>
)}
</div>
</div>
</div>
</div>
</header>
<style>
.fp-header {
/* ---- overridable tokens ---------------------------------------
Every one of these can be set from the props above, or from a
stylesheet in the destination project by targeting the element. */
--fp-top: 12px;
--fp-gutter: 8px;
--fp-max: 1152px;
--fp-max-scrolled: 896px;
--fp-radius: 16px;
--fp-pad: 12px;
--fp-pad-sheet: 20px;
--fp-row: 56px;
--fp-surface: #fafafa;
--fp-panel: #ffffff;
--fp-ground: #fafafa;
--fp-ink: #09090b;
--fp-muted: #52525b;
--fp-line: rgb(9 9 11 / 7.5%);
--fp-ring: rgb(9 9 11 / 10%);
--fp-hover: rgb(9 9 11 / 5%);
--fp-accent: #2c64ff;
--fp-blur: 8px;
--fp-z: 50;
--fp-duration: 500ms;
--fp-ease: cubic-bezier(0.4, 0, 0.2, 1);
--fp-font:
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
position: fixed;
inset-inline: 0;
top: 0;
z-index: var(--fp-z);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
padding-top: var(--fp-top);
/* The dock spans the viewport; only the bar itself takes clicks. */
pointer-events: none;
color: var(--fp-ink);
font-family: var(--fp-font);
font-size: 16px;
line-height: 1.5;
}
/* `:where` keeps specificity at zero, so a destination project's own rules
still win where it means them to. */
.fp-header :where(*, *::before, *::after) {
box-sizing: border-box;
}
.fp-header :where(ul) {
margin: 0;
padding: 0;
list-style: none;
}
.fp-header :where(p) {
margin: 0;
}
.fp-sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* ---------- the floating bar ---------- */
/*
* Every property in the morph is declared here at its resting value and
* overridden once by `[data-fp-scrolled]`, so the reverse is the forward
* transition run backwards and there is no second path to drift out of
* sync. Width is a `min()` of two lengths in both states, which keeps it a
* plain length-to-length transition.
*/
.fp-bar {
pointer-events: auto;
position: relative;
width: min(var(--fp-max), 100% - var(--fp-gutter) * 2);
border-radius: var(--fp-radius);
padding-inline: var(--fp-pad);
background: transparent;
box-shadow: none;
backdrop-filter: blur(0px);
-webkit-backdrop-filter: blur(0px);
transition:
width var(--fp-duration) var(--fp-ease),
padding-inline var(--fp-duration) var(--fp-ease),
background-color var(--fp-duration) var(--fp-ease),
box-shadow var(--fp-duration) var(--fp-ease),
backdrop-filter var(--fp-duration) var(--fp-ease),
-webkit-backdrop-filter var(--fp-duration) var(--fp-ease);
}
[data-fp-scrolled] .fp-bar {
width: min(var(--fp-max-scrolled), 100% - var(--fp-gutter) * 2);
background: color-mix(in srgb, var(--fp-surface) 75%, transparent);
box-shadow:
0 0 0 1px var(--fp-line),
0 4px 6px -1px rgb(0 0 0 / 6.5%),
0 2px 4px -2px rgb(0 0 0 / 6.5%);
backdrop-filter: blur(var(--fp-blur));
-webkit-backdrop-filter: blur(var(--fp-blur));
}
/*
* Three cells: brand, navigation, actions. A grid rather than
* `justify-content: space-between`, because the reference centres its
* navigation on the bar itself — measured at 1440 and at 1026, the nav's
* centre sat within half a pixel of the bar's — and only equal outer
* tracks do that whatever the brand and the actions happen to measure.
*/
.fp-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 16px;
min-height: var(--fp-row);
padding-block: 12px;
}
.fp-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.fp-menu {
/* The two children become the grid's middle and right cells. */
display: contents;
}
/* ---------- brand ---------- */
.fp-brand {
display: inline-flex;
align-items: center;
gap: 8px;
/* 32px, like the nav controls, so the row lands on the reference's
56px bar rather than being pushed taller by the brand alone. On a
phone the whole head row is 56px, so the target grows with it. */
min-height: 32px;
color: var(--fp-ink);
text-decoration: none;
}
.fp-mark {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 20px;
height: 20px;
}
/* Sized here rather than on the element, so a caller's own mark lands at
the same size as the fallback without having to know the number. */
.fp-mark > :global(svg),
.fp-mark > :global(img) {
display: block;
width: 100%;
height: 100%;
}
.fp-mark-fallback {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border: 1.5px solid currentColor;
border-radius: 6px;
font-size: 0.6875rem;
font-weight: 700;
line-height: 1;
text-transform: uppercase;
}
.fp-word {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.015em;
white-space: nowrap;
}
/* ---------- navigation ---------- */
.fp-nav {
grid-column: 2;
justify-self: center;
min-width: 0;
}
.fp-list {
display: flex;
align-items: center;
gap: 12px;
}
.fp-item {
position: relative;
display: flex;
}
.fp-trigger,
.fp-link {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 32px;
padding: 4px 16px;
border: 0;
border-radius: 6px;
background: transparent;
color: var(--fp-muted);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease;
}
.fp-trigger:hover,
.fp-link:hover,
.fp-trigger:focus-visible,
.fp-link:focus-visible,
[data-fp-open] > .fp-trigger {
background: var(--fp-hover);
color: var(--fp-ink);
}
.fp-link[aria-current="page"] {
color: var(--fp-ink);
}
.fp-link.is-inert {
color: var(--fp-muted);
cursor: default;
opacity: 0.65;
}
.fp-chevron {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 200ms var(--fp-ease);
}
[data-fp-open] .fp-chevron {
transform: rotate(180deg);
}
/* ---------- dropdown panels ---------- */
/*
* Centred under the trigger. The reference offsets its panel by a third of
* its own width, which lands within a few pixels of centred for the two
* panels it ships; centring is the same picture and cannot drift off the
* bar when a panel is wider or narrower than theirs.
*/
.fp-panel {
display: none;
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
margin-top: 6px;
width: max-content;
max-width: min(560px, calc(100vw - 32px));
border-radius: 12px;
background: var(--fp-ground);
box-shadow:
0 0 0 1px var(--fp-line),
0 10px 15px -3px rgb(0 0 0 / 6.5%),
0 4px 6px -4px rgb(0 0 0 / 6.5%);
}
[data-fp-open] > .fp-panel,
/* Without the controller, hover and focus are the only way in — and they
have to work, or the panels' links are unreachable. */
.fp-header:not([data-fp-ready]) .fp-item:hover > .fp-panel,
.fp-header:not([data-fp-ready]) .fp-item:focus-within > .fp-panel {
display: block;
animation: fp-panel-in 200ms var(--fp-ease) both;
}
@keyframes fp-panel-in {
from {
opacity: 0;
transform: translateX(-50%) scale(0.97);
}
}
.fp-columns {
display: flex;
gap: 6px;
}
/* The elevated column: rows with descriptions, on a raised card. */
.fp-column.is-cards {
width: 272px;
border-radius: 12px;
padding: 2px;
padding-top: 8px;
background: var(--fp-panel);
box-shadow:
0 0 0 1px var(--fp-line),
0 1px 3px rgb(0 0 0 / 8%),
0 1px 2px -1px rgb(0 0 0 / 8%);
}
/* The plain column: label-only rows, straight onto the panel ground. */
.fp-column.is-list {
width: 236px;
padding: 8px 2px 2px;
}
.fp-heading {
margin-inline-start: 12px;
margin-bottom: 4px;
color: var(--fp-muted);
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
text-transform: uppercase;
}
.fp-cell {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 11px;
color: var(--fp-ink);
text-decoration: none;
transition: background-color 150ms ease;
}
.is-cards .fp-cell {
padding: 12px;
}
.fp-cell:hover,
.fp-cell:focus-visible {
background: var(--fp-hover);
}
.fp-cell.is-inert {
cursor: default;
opacity: 0.65;
}
.fp-cell[aria-current="page"] {
background: rgb(9 9 11 / 3%);
}
.fp-tile {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 8px;
color: var(--fp-muted);
}
/* Only the card rows get the raised tile; the list rows show a bare icon,
exactly as the reference draws them. */
.is-cards .fp-tile {
width: 36px;
height: 36px;
background: var(--fp-panel);
box-shadow:
0 0 0 1px var(--fp-ring),
0 1px 3px rgb(0 0 0 / 10%),
0 1px 2px -1px rgb(0 0 0 / 10%);
background-image: radial-gradient(
circle at 50% 0%,
rgb(9 9 11 / 3%),
transparent 70%
);
}
.fp-icon {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
}
.fp-cell-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.fp-cell-title {
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
}
.fp-cell-desc {
overflow: hidden;
color: var(--fp-muted);
font-size: 0.75rem;
line-height: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.fp-panel-foot {
padding: 14px 16px 16px;
}
.fp-panel-more {
color: var(--fp-accent);
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
.fp-panel-more:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
/* ---------- actions ---------- */
.fp-actions {
display: flex;
grid-column: 3;
justify-self: end;
align-items: center;
gap: 12px;
}
.fp-ghost,
.fp-cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding-inline: 12px;
border-radius: 6px;
color: var(--fp-ink);
font-size: 0.75rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition:
background-color 150ms ease,
box-shadow 150ms ease;
}
.fp-ghost:hover {
background: var(--fp-hover);
}
.fp-cta {
background: var(--fp-panel);
box-shadow:
0 0 0 1px var(--fp-line),
0 1px 2px rgb(0 0 0 / 6.5%);
}
.fp-cta:hover {
box-shadow:
0 0 0 1px var(--fp-ring),
0 1px 3px rgb(0 0 0 / 10%);
}
/* ---------- menu button ---------- */
.fp-burger {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 44px;
height: 44px;
margin-inline-end: -10px;
padding: 0;
border: 0;
border-radius: 10px;
background: transparent;
color: var(--fp-ink);
cursor: pointer;
}
.fp-burger-icon {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.fp-burger-icon path {
transform-origin: 12px 12px;
transition:
transform 200ms var(--fp-ease),
opacity 150ms ease;
}
[data-fp-sheet-open] .fp-burger-top {
transform: translateY(5px) rotate(45deg);
}
[data-fp-sheet-open] .fp-burger-mid {
opacity: 0;
}
[data-fp-sheet-open] .fp-burger-bottom {
transform: translateY(-5px) rotate(-45deg);
}
/* ---------- small screens ----------
The breakpoint is a prop, so the controller mirrors it onto the root as
`data-fp-mobile` and the layout keys off that attribute rather than a
media query, which could not read the prop. The no-script fallback needs
a genuine media query, so it is emitted per instance in the markup above
at the same configured value. */
[data-fp-mobile] .fp-row {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0;
padding-block: 0;
}
[data-fp-mobile] .fp-head {
min-height: var(--fp-row);
}
[data-fp-mobile] .fp-burger {
display: inline-flex;
}
[data-fp-mobile] .fp-menu {
display: none;
}
[data-fp-mobile][data-fp-sheet-open] .fp-menu {
display: flex;
flex-direction: column;
width: 100%;
padding-bottom: 12px;
animation: fp-sheet-in 220ms var(--fp-ease) both;
}
@keyframes fp-sheet-in {
from {
opacity: 0;
transform: translateY(-6px);
}
}
/* The open sheet: a full-screen frosted ground with the bar as its card. */
[data-fp-mobile][data-fp-sheet-open].fp-header {
bottom: 0;
pointer-events: auto;
background: color-mix(in srgb, var(--fp-panel) 75%, transparent);
backdrop-filter: blur(var(--fp-blur));
-webkit-backdrop-filter: blur(var(--fp-blur));
}
[data-fp-mobile][data-fp-sheet-open] .fp-bar {
max-height: calc(100dvh - var(--fp-top) * 2);
overflow-y: auto;
padding-inline: var(--fp-pad-sheet);
background: color-mix(in srgb, var(--fp-surface) 75%, transparent);
box-shadow:
0 0 0 1px var(--fp-line),
0 4px 6px -1px rgb(0 0 0 / 6.5%),
0 2px 4px -2px rgb(0 0 0 / 6.5%);
backdrop-filter: blur(var(--fp-blur));
-webkit-backdrop-filter: blur(var(--fp-blur));
}
[data-fp-mobile][data-fp-sheet-open] .fp-head {
border-bottom: 1px solid var(--fp-line);
}
[data-fp-mobile] .fp-nav {
justify-self: stretch;
}
[data-fp-mobile] .fp-list {
flex-direction: column;
align-items: stretch;
gap: 0;
}
[data-fp-mobile] .fp-item {
flex-direction: column;
}
[data-fp-mobile] .fp-trigger,
[data-fp-mobile] .fp-link {
justify-content: space-between;
width: 100%;
min-height: 52px;
padding-inline: 0;
border-radius: 0;
border-bottom: 1px solid var(--fp-line);
color: var(--fp-ink);
font-size: 1.0625rem;
}
[data-fp-mobile] .fp-trigger {
font-weight: 600;
}
[data-fp-mobile] .fp-link {
font-weight: 400;
}
[data-fp-mobile] .fp-item[data-fp-open] > .fp-trigger {
margin-inline: -12px;
padding-inline: 12px;
border-bottom-color: transparent;
border-radius: 10px;
width: auto;
}
/* In the flow, not a popup: the panel becomes the accordion's contents. */
[data-fp-mobile] .fp-panel {
position: static;
transform: none;
width: auto;
max-width: none;
margin-top: 0;
padding-bottom: 8px;
border-radius: 0;
background: none;
box-shadow: none;
}
[data-fp-mobile] .fp-item[data-fp-open] > .fp-panel {
animation: none;
}
[data-fp-mobile] .fp-columns {
flex-direction: column;
gap: 0;
}
[data-fp-mobile] .fp-column.is-cards,
[data-fp-mobile] .fp-column.is-list {
width: auto;
padding: 0;
border-radius: 0;
background: none;
box-shadow: none;
}
[data-fp-mobile] .fp-heading {
margin-block: 8px 2px;
margin-inline-start: 4px;
}
[data-fp-mobile] .fp-cell,
[data-fp-mobile] .is-cards .fp-cell {
min-height: 44px;
padding: 8px 4px;
}
/* One row height for every entry on a phone: the descriptions are the
first thing to go, exactly as the reference drops them. */
[data-fp-mobile] .fp-cell-desc {
display: none;
}
[data-fp-mobile] .is-cards .fp-tile {
width: 20px;
height: 20px;
border-radius: 0;
background: none;
background-image: none;
box-shadow: none;
}
[data-fp-mobile] .fp-panel-foot {
padding: 4px 4px 8px;
}
[data-fp-mobile] .fp-actions {
flex-direction: column;
align-items: stretch;
justify-self: stretch;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--fp-line);
}
[data-fp-mobile] .fp-ghost,
[data-fp-mobile] .fp-cta {
min-height: 44px;
font-size: 0.8125rem;
}
/* ---------- focus ---------- */
.fp-brand:focus-visible,
.fp-trigger:focus-visible,
.fp-link:focus-visible,
.fp-cell:focus-visible,
.fp-panel-more:focus-visible,
.fp-ghost:focus-visible,
.fp-cta:focus-visible,
.fp-burger:focus-visible {
outline: 3px solid color-mix(in srgb, var(--fp-accent) 50%, transparent);
outline-offset: 1px;
border-radius: 6px;
}
/* ---------- reduced motion ----------
Every state change still happens; it just arrives at once. Nothing is
hidden and nothing is left mid-transition. */
@media (prefers-reduced-motion: reduce) {
.fp-header {
--fp-duration: 1ms;
}
.fp-chevron,
.fp-trigger,
.fp-link,
.fp-cell,
.fp-ghost,
.fp-cta,
.fp-burger-icon path {
transition-duration: 1ms;
}
[data-fp-open] > .fp-panel,
.fp-header:not([data-fp-ready]) .fp-item:hover > .fp-panel,
.fp-header:not([data-fp-ready]) .fp-item:focus-within > .fp-panel,
[data-fp-mobile][data-fp-sheet-open] .fp-menu {
animation-duration: 1ms;
}
}
</style>
<script>
/**
* One controller per instance, one boot per page view.
*
* A header is installed once into a shared layout, so it has to survive a
* destination project running <ClientRouter />: the document is swapped
* without this module being re-evaluated, the element is replaced, and
* anything left on `document` or `document.body` carries into the next
* route. Hence: boot on `astro:page-load` as well as first load,
* idempotent init, and every listener bound to an AbortController that is
* aborted before the swap.
*/
/*
* Scroll locking is a page-level concern, so it is counted at module scope
* rather than per instance. Two headers on one page would otherwise have
* the second one's close restore scrolling while the first still has its
* sheet open over it.
*/
let scrollLocks = 0;
let savedOverflow = "";
let savedPaddingRight = "";
let savedScrollY = 0;
function lockScroll() {
if (scrollLocks++ > 0) return;
savedScrollY = window.scrollY;
savedOverflow = document.body.style.overflow;
savedPaddingRight = document.body.style.paddingRight;
/* Replace the scrollbar's width with padding, so locking does not shift
the page sideways under the fixed header. */
const gap = window.innerWidth - document.documentElement.clientWidth;
if (gap > 0) document.body.style.paddingRight = `${gap}px`;
document.body.style.overflow = "hidden";
}
function releaseScroll() {
if (scrollLocks === 0) return;
if (--scrollLocks > 0) return;
/* Restore exactly what was there, rather than assuming a default. */
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPaddingRight;
if (window.scrollY !== savedScrollY) window.scrollTo(0, savedScrollY);
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function initFloatingPanelHeader(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.fpReady === "") return;
const bar = root.querySelector<HTMLElement>("[data-fp-bar]");
const menu = root.querySelector<HTMLElement>("[data-fp-menu]");
const burger = root.querySelector<HTMLButtonElement>("[data-fp-burger]");
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-fp-item]"));
if (!bar) return;
/* Enables every JS-driven state. Until it is set the CSS keeps panels on
hover and focus-within and, below the breakpoint, the navigation in
the flow — so nothing is unreachable if this never runs. */
root.dataset.fpReady = "";
const controller = new AbortController();
const { signal } = controller;
const threshold = Number(root.dataset.fpThreshold) || 0;
const breakpoint = Number(root.dataset.fpBreakpoint) || 1024;
const wide = window.matchMedia(`(min-width: ${breakpoint + 1}px)`);
/* ---- scroll state ---- */
let frame = 0;
function applyScrollState() {
const past = window.scrollY > threshold;
if (past === root.hasAttribute("data-fp-scrolled")) return;
root.toggleAttribute("data-fp-scrolled", past);
}
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
applyScrollState();
});
}
window.addEventListener("scroll", schedule, { passive: true, signal });
applyScrollState();
/* ---- dropdown panels ---- */
let openItem: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function setPanel(item: HTMLElement | null) {
if (openItem === item) return;
/* One panel at a time within this instance, and never across two:
`items` was queried from this root, so another header's panels are
not in the list to be closed. */
for (const other of items) {
const on = other === item;
other.toggleAttribute("data-fp-open", on);
other
.querySelector<HTMLButtonElement>("[data-fp-trigger]")
?.setAttribute("aria-expanded", String(on));
}
openItem = item;
}
/* A short grace period, so the pointer can cross the gap between a
trigger and the panel below it without the panel vanishing. */
function scheduleClose() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => setPanel(null), 220);
}
function cancelClose() {
clearTimeout(closeTimer);
}
for (const item of items) {
const trigger = item.querySelector<HTMLButtonElement>("[data-fp-trigger]");
if (!trigger) continue;
/* Hover opens at desktop widths, matching the reference. Guarded on
pointer type: a touch fires `pointerenter` immediately before the
click, which would otherwise open and then immediately close. */
item.addEventListener(
"pointerenter",
(event) => {
if (event.pointerType !== "mouse" || !wide.matches) return;
cancelClose();
setPanel(item);
},
{ signal },
);
item.addEventListener(
"pointerleave",
(event) => {
if (event.pointerType !== "mouse" || !wide.matches) return;
scheduleClose();
},
{ signal },
);
/* Enter and Space both reach this through a <button>'s native click. */
trigger.addEventListener(
"click",
() => {
cancelClose();
setPanel(openItem === item ? null : item);
},
{ signal },
);
/* Tabbing out of the last link in a panel closes it, so focus never
sits behind an invisible layer. Not applied inside the sheet,
where the panel is an accordion in the flow and closing it under
a moving focus would collapse the list being read. */
item.addEventListener(
"focusout",
(event) => {
if (!wide.matches) return;
const next = event.relatedTarget as Node | null;
if (next && item.contains(next)) return;
if (openItem === item) setPanel(null);
},
{ signal },
);
}
/* ---- the sheet ---- */
let sheetOpen = false;
let lastFocused: HTMLElement | null = null;
const sheetFocusables = () =>
menu ? Array.from(menu.querySelectorAll<HTMLElement>(FOCUSABLE)) : [];
function setSheet(next: boolean) {
if (!burger || !menu || sheetOpen === next) return;
sheetOpen = next;
root.toggleAttribute("data-fp-sheet-open", next);
burger.setAttribute("aria-expanded", String(next));
if (next) {
lastFocused = document.activeElement as HTMLElement | null;
lockScroll();
const first = sheetFocusables()[0];
(first ?? menu).focus({ preventScroll: true });
} else {
releaseScroll();
/* Any accordion left open inside a closed sheet would keep an
`aria-expanded="true"` on a control nobody can see. */
setPanel(null);
/* Focus returns to the control that opened it — but only if focus
is still inside the sheet, so a link the user followed is not
yanked back. */
const active = document.activeElement as HTMLElement | null;
if (!active || menu.contains(active) || active === document.body) {
(lastFocused ?? burger).focus({ preventScroll: true });
}
lastFocused = null;
}
}
/* Focusable only as a fallback target, when it contains nothing else. */
menu?.setAttribute("tabindex", "-1");
burger?.addEventListener("click", () => setSheet(!sheetOpen), { signal });
/* Choosing a route closes the sheet. */
menu?.addEventListener(
"click",
(event) => {
if (!sheetOpen) return;
if ((event.target as HTMLElement).closest("a[href]")) setSheet(false);
},
{ signal },
);
/* ---- shared dismissal ---- */
document.addEventListener(
"keydown",
(event) => {
if (event.key === "Escape") {
if (openItem && wide.matches) {
const trigger = openItem.querySelector<HTMLButtonElement>("[data-fp-trigger]");
setPanel(null);
trigger?.focus({ preventScroll: true });
return;
}
if (sheetOpen) {
event.preventDefault();
setSheet(false);
burger?.focus({ preventScroll: true });
}
return;
}
if (event.key !== "Tab" || !sheetOpen || !menu) return;
/* Focus trap for the open sheet. The menu button is deliberately
part of the cycle: it is the control that closes the sheet, and
putting it out of reach would strand a keyboard user inside. */
const cycle = sheetFocusables();
if (burger) cycle.unshift(burger);
if (cycle.length === 0) return;
const first = cycle[0];
const last = cycle[cycle.length - 1];
const active = document.activeElement as HTMLElement | null;
if (event.shiftKey && (active === first || !cycle.includes(active as HTMLElement))) {
event.preventDefault();
last.focus({ preventScroll: true });
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus({ preventScroll: true });
}
},
{ signal },
);
/*
* Outside-click. The open sheet's ground covers the viewport and is part
* of this root, so "outside the root" is not the test that closes it —
* "outside the bar" is. At desktop widths the two are the same thing.
*/
document.addEventListener(
"pointerdown",
(event) => {
const target = event.target as Node;
if (sheetOpen && !bar!.contains(target)) {
setSheet(false);
return;
}
if (root.contains(target)) return;
setPanel(null);
},
{ signal },
);
/* ---- breakpoint ---- */
function applyBreakpoint() {
root.toggleAttribute("data-fp-mobile", !wide.matches);
/* Crossing to desktop with the sheet open would otherwise leave the
body locked and a hidden sheet still flagged open. */
if (wide.matches && sheetOpen) setSheet(false);
if (!wide.matches && openItem) setPanel(null);
}
if (typeof wide.addEventListener === "function") {
wide.addEventListener("change", applyBreakpoint, { signal });
}
applyBreakpoint();
/*
* Astro replaces the document on a client-side navigation. Close first,
* so a sheet that was open does not leave the body locked on the page
* being navigated to; then detach, and drop the ready flag so a re-used
* element can be booted again.
*/
document.addEventListener(
"astro:before-swap",
() => {
if (frame) cancelAnimationFrame(frame);
clearTimeout(closeTimer);
setSheet(false);
setPanel(null);
controller.abort();
delete root.dataset.fpReady;
},
{ once: true, signal },
);
}
const boot = () =>
document
.querySelectorAll<HTMLElement>("[data-fp-root]")
.forEach(initFloatingPanelHeader);
/* 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>
===== END src/components/FloatingPanelHeader.astro =====
===== FILE (supporting): src/components/FloatingPanelHeader.nav-data.ts =====
/**
* FloatingPanelHeader — navigation data, worked example.
*
* Optional. The component ships a full demo tree as its own default, so it
* renders without this file. What this is for is the thing that actually goes
* wrong when a header is installed somewhere real: the destination project has
* its own routes, and someone has to map them into this shape. Doing that in a
* data file rather than inline in a layout means the routes can be read,
* reviewed and changed without touching markup.
*
* Copy it next to the component, edit every label and href to match the
* destination's real routes, and pass it in:
*
* ---
* import Header from "../components/FloatingPanelHeader.astro";
* import { primaryNav } from "../components/FloatingPanelHeader.nav-data";
* ---
* <Header items={primaryNav} currentPath={Astro.url.pathname} />
*
* The types come from the component itself, so this file cannot drift from
* what the component accepts: rename a field there and `astro check` fails
* here.
*/
import type { NavItem } from "./FloatingPanelHeader.astro";
/**
* Every route in the example tree is a placeholder. Replace them with routes
* that exist in the destination project — a header pointing at four 404s is
* worse than no header, because it looks finished.
*
* Shape rules the component enforces:
* - an item has `href` OR `groups`, never both;
* - an item with `groups` renders as a dropdown trigger (a real `<button>`);
* - an item with `href` renders as a plain link;
* - a group whose links carry `description` is drawn as the raised card
* column with icon tiles; a group whose links do not is drawn as the
* compact list column beside it. That is the only thing that decides it,
* so mixing both inside one group gives you card rows with a blank second
* line;
* - `footer` renders a closing link under the columns, and is ignored on an
* item with no `groups`;
* - `icon` names one of the built-in icons — bolt, chart, shield, spark,
* compass, gem, code, chat, book, layers, gauge, users, lock, store, dot.
* An unknown name falls back to `dot` rather than an empty tile.
*/
export const primaryNav: NavItem[] = [
{
label: "Product",
groups: [
{
heading: "Capabilities",
links: [
{
label: "Overview",
href: "/product",
description: "What it does, in one page",
icon: "compass",
},
{
label: "Integrations",
href: "/product/integrations",
description: "What it talks to",
icon: "layers",
},
{
label: "Security",
href: "/product/security",
description: "How access is handled",
icon: "lock",
},
],
},
],
footer: { label: "See the whole product", href: "/product" },
},
{
label: "Resources",
groups: [
{
heading: "Learn",
links: [
{
label: "Guides",
href: "/guides",
description: "Short, practical walkthroughs",
icon: "book",
},
{
label: "Changelog",
href: "/changelog",
description: "What shipped, and when",
icon: "gauge",
},
],
},
{
/* No descriptions in this group, so it renders as the compact
list column beside the card one. */
heading: "Community",
links: [
{ label: "Forum", href: "/community", icon: "users" },
{ label: "Open source", href: "/open-source", icon: "code" },
],
},
],
},
/* Plain destinations: no `groups`, so no panel and no trigger button. */
{ label: "Pricing", href: "/pricing" },
{ label: "Contact", href: "/contact" },
];
===== END src/components/FloatingPanelHeader.nav-data.ts =====
===== FILE (supporting): src/components/FloatingPanelHeader.md =====
[read this one from the library page: src/components/library/FloatingPanelHeader.md]
--- 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.
--- 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.