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.
*
* The component ships no menu of its own (Step 14): pass `items` or the header
* renders no navigation. This file is a worked example of that data. What it 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 {
// No invented brand, navigation tree or actions: the site supplies its own.
brandName = "",
brandHref = "/",
navLabel = "Primary",
menuLabel = "Menu",
items = [],
loginLabel = "",
loginHref,
ctaLabel = "",
ctaHref,
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),
};
/*
* Nothing is invented when data is missing. Each block renders only when it
* has content; with no navigation and no action there is nothing for the menu
* button to open, so it and the menu are not rendered; with no brand either,
* there is no header at all.
*/
const hasBrand = brandLabel !== "" || Astro.slots.has("brand-mark");
const hasActions = Boolean((login.label && login.href) || (cta.label && cta.href));
const hasMenu = nav.length > 0 || hasActions;
const hasContent = hasBrand || hasMenu;
/* 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";
---
{hasContent && (
<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">
{hasBrand && (
<Brand
class="fp-brand"
href={homeHref}
aria-label={homeHref ? (brandLabel ? `${brandLabel} home` : "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>
)}
{hasMenu && (
<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.
*/}
{hasMenu && (
<div class="fp-menu" id={menuId} data-fp-menu>
{nav.length > 0 && (
<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>
)}
{hasActions && (
<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);
}
/* Actions with no navigation above them: no rule separating them from
nothing. */
[data-fp-mobile] .fp-actions:first-child {
padding-top: 0;
border-top: 0;
}
[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.
*
* The component ships no menu of its own (Step 14): pass `items` or the header
* renders no navigation. This file is a worked example of that data. What it 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 =====
# Floating panel header
A fixed site header that floats over the page as a rounded bar — transparent at
the top of a route — and narrows into frosted glass once you scroll: 1152px
down to 896px, a translucent fill, a hairline ring, a soft shadow and a
backdrop blur, all on one 500ms transition. Scroll back to the top and the
whole thing reverses.
Its navigation drops panels of icon rows: a raised card column for links that
carry a description, a compact list column for links that do not, and an
optional closing link under both. At and below the breakpoint — 1024px by
default, and genuinely configurable — the bar becomes a menu button over a
full-screen sheet, and the same panels become accordions inside it.
Catalog page: `/components/floating-panel-header`
## Purpose
A marketing header for a site with more than five destinations: enough
structure for two panels of sub-navigation, without the full-bleed mega menu
that makes a small site look like it is pretending. The narrowing is the point
— at the top of a page it is a wide, quiet bar, and over the rest of the page
it is a compact object that does not pretend to be part of the content.
> **Design credit.** The behaviour was inspired by the public
> [Tailark Quartz "header five"](https://pro.tailark.com/preview/quartz/header/five)
> preview, which was observed in a browser at several widths, scroll positions
> and menu states and rebuilt from those measurements. **No source code,
> markup, CSS, script, branding, logo or artwork from that page is used here,
> and every word of the demo content is original.** The reference is a
> React/Tailwind block on a headless menu library; this is one Astro file with
> scoped CSS and about 200 lines of dependency-free TypeScript. Differences are
> listed under
> [Measurable differences from the reference](#measurable-differences-from-the-reference).
## Files required
**`FloatingPanelHeader.astro`** — one file, the whole component.
- No imports — not from this catalog, not from anywhere.
- No npm packages, no Astro built-ins, no framework.
- No global stylesheet. Every colour, size, breakpoint, duration and easing it
needs is declared on its own root element as a `--fp-*` custom property, so
it inherits nothing from the host page and leaks nothing back.
- No webfonts, no images, no icon package, no network requests of any kind —
the fifteen panel icons are drawn in the file.
- The only assumption about the host is that Astro compiles `.astro` files.
`FloatingPanelHeader.nav-data.ts` — a worked navigation tree, mapped onto a
destination project's own routes. Optional: you can pass `items` inline
instead. The component has no tree of its own, so one or the other is how the
header gets any navigation at all.
`FloatingPanelHeader.md` — this file. Documentation, not required to run.
## Basic usage
```astro
---
import Header from "../components/global/FloatingPanelHeader.astro";
import { primaryNav } from "../components/global/FloatingPanelHeader.nav-data";
---
<Header
brandName="Your brand"
brandHref="/"
items={primaryNav}
currentPath={Astro.url.pathname}
loginLabel="Sign in"
loginHref="/signin"
ctaLabel="Talk to sales"
ctaHref="/contact"
/>
```
## The navigation tree
```ts
const items = [
{
label: "Product",
groups: [
{
heading: "Capabilities",
links: [
{ label: "Automations", href: "/product/automations",
description: "Rules that run without you", icon: "bolt" },
],
},
],
footer: { label: "See the whole product", href: "/product" },
},
{ label: "Pricing", href: "/pricing" },
];
```
| Give an item | and you get |
| :--- | :--- |
| `href` | a plain link in the bar |
| `groups` | a real `<button>` trigger with a panel under it |
| both | `groups` wins — the item becomes a trigger |
| `footer` | a closing link under the panel's columns (ignored without `groups`) |
**What decides how a column is drawn is whether its links have a
`description`.** A group whose links have one is the raised card column with
36px icon tiles and two lines per row; a group whose links have none is the
compact list column beside it, one 16px icon and one line per row. That is the
reference's own rule, and it means a column changes shape by changing its
content rather than by setting a flag. Mixing both inside one group gives you
card rows with a blank second line.
### Icons
`icon` names one of fifteen drawn in the component: `bolt`, `chart`, `shield`,
`spark`, `compass`, `gem`, `code`, `chat`, `book`, `layers`, `gauge`, `users`,
`lock`, `store`, `dot`. An unknown or missing name falls back to `dot` rather
than rendering an empty tile. There is **no prop that takes markup** and the
component never uses `set:html`; a name is the whole interface, which is what
keeps an icon out of the untrusted-data path.
## Props
The component ships no brand, navigation tree or actions of its own — the
catalog preview passes its demo tree as props, and `<Header />` alone renders
nothing. Each block renders only when it has content: no brand word and no
`brand-mark` slot, no brand block; no items, no `<nav>`; no complete action,
no action group; no items and no action, no menu button and no sheet.
| Prop | Type | Default | What it does |
| :--- | :--- | :--- | :--- |
| `brandName` | `string` | `""` | Wordmark beside the mark. `""` with a `brand-mark` slot renders the mark alone, named "Home"; `""` without one renders no brand block. |
| `brandHref` | `string` | `"/"` | Where the brand links to. Unsafe or omitted renders a non-linked wordmark. |
| `navLabel` | `string` | `"Primary"` | Accessible name for the `<nav>` landmark. |
| `menuLabel` | `string` | `"Menu"` | Accessible name for the menu button. |
| `items` | `NavItem[]` | `[]` | The navigation. |
| `loginLabel` / `loginHref` | `string` | `""` / — | Secondary action. Rendered only when both are given. |
| `ctaLabel` / `ctaHref` | `string` | `""` / — | Primary action. Rendered only when both are given. |
| `currentPath` | `string?` | — | Marks the matching link `aria-current="page"`. Compared with query, hash and trailing slashes removed. |
| `scrollThreshold` | `number` | `50` | Pixels of scroll before it narrows. Reversal happens at the same point. |
| `breakpoint` | `number` | `1024` | At and below this width the sheet replaces the inline navigation. Drives the controller, the CSS layout and the no-JS fallback alike. |
| `topOffset` | `string` | `"12px"` | Distance from the top of the viewport. |
| `maxWidth` | `string` | `"1152px"` | Bar width at rest. |
| `scrolledMaxWidth` | `string` | `"896px"` | Bar width once narrowed. |
| `surface` | `string` | `"#fafafa"` | Bar fill once narrowed, mixed to 75% so the blur reads. |
| `panelBackground` | `string` | `"#ffffff"` | Panel cards, icon tiles and the primary button. |
| `ink` | `string` | `"#09090b"` | Primary text. |
| `muted` | `string` | `"#52525b"` | Navigation labels, descriptions, headings. |
| `accent` | `string` | `"#2c64ff"` | Panel footer link and the focus ring. |
| `blur` | `string` | `"8px"` | Backdrop blur for the narrowed bar and the sheet. `"0px"` turns the glass off. |
| `zIndex` | `number` | `50` | Stacking order. |
| `class` | `string?` | — | Extra class on the `<header>`. |
| `id` | `string?` | — | Header id. Also seeds the panel and menu ids instead of a random one. |
### `PanelLink`
| Field | Type | What it does |
| :--- | :--- | :--- |
| `label` | `string` | Visible text. A row without one is dropped. |
| `href` | `string` | Destination. An unsafe or missing value renders as plain text, never a link. |
| `description` | `string?` | Second line — and the thing that makes the column a card. |
| `icon` | `string?` | One of the fifteen names above. |
| `external` | `boolean?` | Opens in a new tab with `rel="noopener noreferrer"`. |
### Link data is validated
Link data often comes from a CMS or a config file, and a header renders on
every route, so a bad `href` would be a bad `href` site-wide. Every value —
panel links, plain links, the panel footer, both actions and `brandHref` —
goes through the same check before it becomes an attribute:
- **Allowed:** root-relative (`/about/`), bare relative (`about/`), fragments
(`#section`), `./` and `../`, and `http:`, `https:`, `mailto:`, `tel:`.
- **Refused:** everything else, including `javascript:`, `data:` and
`vbscript:` in any casing, with leading whitespace, or with control
characters inserted mid-scheme (`java<TAB>script:`). Control characters are
stripped before the check, so that trick does not get past it.
- A refused `href` renders the label as **plain text** rather than a link —
visible and inert, instead of silently disappearing.
- Entries that are `null`, or have no label, are dropped; a group left with no
links is dropped with them.
Verified in a browser against `javascript:`, `JaVaScRiPt:`, ` javascript:`,
`java<TAB>script:` and a `data:text/html` payload, in panel rows, a plain nav
item, the panel footer, the login action and `brandHref`: all of them rendered
as inert text, no `javascript:` or `data:` attribute reached the DOM, clicking
each one changed nothing and fired no dialog, and the brand rendered as a
`<span>`.
## Named slots
| Slot | What goes in it |
| :--- | :--- |
| `brand-mark` | An inline SVG or `<img>` for the brand mark. Sized to 20×20. Falls back to the brand initial in a ruled square. |
There is no default slot: a header's contents are its brand, its navigation and
its actions, and all three are props.
## CSS custom properties
Set any of these on the element — with `class`, an `id`, or a wrapper rule — to
retune it without touching the file. The props above are shorthands for the
common ones.
| Property | Default | What it controls |
| :--- | :--- | :--- |
| `--fp-top` | `12px` | Distance from the top of the viewport |
| `--fp-gutter` | `8px` | Space between the bar and the viewport edge |
| `--fp-max` | `1152px` | Bar width at rest |
| `--fp-max-scrolled` | `896px` | Bar width once narrowed |
| `--fp-radius` | `16px` | Bar corner radius |
| `--fp-pad` | `12px` | Bar inline padding |
| `--fp-pad-sheet` | `20px` | Bar inline padding in the open sheet |
| `--fp-row` | `56px` | Bar height, constant across both states |
| `--fp-surface` | `#fafafa` | Narrowed bar fill (prop: `surface`) |
| `--fp-panel` | `#ffffff` | Panel cards and tiles (prop: `panelBackground`) |
| `--fp-ground` | `#fafafa` | Panel ground behind the cards |
| `--fp-ink` | `#09090b` | Primary text (prop: `ink`) |
| `--fp-muted` | `#52525b` | Secondary text (prop: `muted`) |
| `--fp-line` | `rgb(9 9 11 / 7.5%)` | Hairline rings and separators |
| `--fp-ring` | `rgb(9 9 11 / 10%)` | Icon tile ring |
| `--fp-hover` | `rgb(9 9 11 / 5%)` | Hover and open-trigger fill |
| `--fp-accent` | `#2c64ff` | Panel footer link and focus ring (prop: `accent`) |
| `--fp-blur` | `8px` | Backdrop blur (prop: `blur`) |
| `--fp-z` | `50` | Stacking order (prop: `zIndex`) |
| `--fp-duration` | `500ms` | Morph duration |
| `--fp-ease` | `cubic-bezier(0.4, 0, 0.2, 1)` | Morph easing |
| `--fp-font` | system sans stack | Font stack |
## How the morph works
Every property is declared once at its resting value on `.fp-bar` and
overridden once by `[data-fp-scrolled]`, which a small controller toggles. There
is no per-frame scripting, no measurement pass and no animation library, so the
reverse is the forward transition run backwards and there is no second code
path to drift out of sync.
| | At rest | Narrowed |
| :--- | :--- | :--- |
| width | `min(1152px, 100% - 16px)` | `min(896px, 100% - 16px)` |
| background | `transparent` | `surface` at 75% |
| box-shadow | none | 1px ring + a soft two-layer shadow |
| backdrop-filter | `blur(0px)` | `blur(8px)` |
| height | 56px | 56px |
Both states are a `min()` of two plain lengths, so the width transition never
depends on interpolating to a keyword, and the height never changes — the bar
is `position: fixed`, so nothing in the page moves at any point. Sampled at
60ms intervals through a real transition at 1440px: 1152 → 1136 → 1073 → 983 →
932 → 912 → 901 → 896, with height constant at 56px and `visibility` never
leaving `visible`.
The threshold holds in both directions: still wide at scrollY 50, narrowed at
51, and wide again at 0.
## One set of markup, two layouts
The desktop bar and the small-screen sheet are the same elements. A panel is an
absolutely positioned popup under its trigger above the breakpoint and an
in-flow accordion inside the sheet below it — same `<button>`, same
`aria-expanded`, same `aria-controls`, same links.
That is worth stating plainly because the usual approach is to render the
navigation twice and hide one copy: two copies means duplicate ids, two sets of
links in the accessibility tree, and a link that is "hidden" on desktop but
still in the tab order the day a CSS rule changes. Here there is exactly one
copy of every link at every width — measured as five visible controls at 320,
390, 768, 834 and 1024, and seven at 1025, 1280, 1440 and 1920.
The sheet is the bar itself: it grows, gains a hairline under the brand row,
and sits on a frosted full-screen ground. Nothing slides in from anywhere.
## Accessibility behaviour
- Semantic `<header>`, `<nav>`, `<ul>`/`<li>`, real `<a>` links and real
`<button>` triggers. No `div` acting as a control.
- Each trigger is a **disclosure**: `aria-expanded` plus `aria-controls`
pointing at its own panel's per-instance id. Deliberately no
`aria-haspopup="menu"` — the panel is a group of links, not a menu, and
saying otherwise would promise arrow-key semantics it does not have.
- The menu button carries `aria-expanded`, `aria-controls` and a
visually-hidden label as its accessible name.
- `aria-current="page"` on the link matching `currentPath`, in the bar and in
every panel.
- **Closed panels are not focusable.** They are `display: none` until opened —
measured as a four-stop desktop tab order (brand, Product, Solutions,
Pricing) with the panels closed, and two stops on a phone with the sheet
closed (brand, menu button).
- **Keyboard opens every panel.** Enter and Space both toggle a trigger; Tab
then moves into the panel's rows.
- **Escape closes** the open panel and returns focus to its trigger, or closes
the sheet and returns focus to the menu button. It is bound to the document,
so it works wherever focus happens to be — including where a client-side
router leaves it.
- Tabbing out of the last row of a panel closes it, so focus never sits behind
an invisible layer. Inside the sheet that rule is off: the accordion is in
the flow, and collapsing it under a moving focus would close the list being
read.
- **Focus moves into the sheet** when it opens, and is **trapped** while it is
open — measured as a seven-stop cycle including the menu button, which is
deliberately part of it because it is the control that closes the sheet.
- Choosing any link closes the sheet; so does an outside tap, or Escape.
- Focus returns to the menu button on close — unless focus has already moved to
wherever a followed link took it.
- Touch targets: the menu button is 44×44, sheet rows are 52px, panel rows in
the sheet 44px, and the actions 44px. Desktop nav controls are the
reference's 32px, which clears the 24×24 minimum for a pointer.
- Visible focus rings on every control: 3px in `--fp-accent` at 50%.
- The header is `position: fixed` but its dock is `pointer-events: none`, so it
never intercepts clicks meant for the page. The dock takes pointer events
only while the sheet is open, which is what makes the outside tap work.
- **Skip links keep working.** The component adds nothing before itself in the
DOM, so a skip link the layout puts first stays the first focusable element —
verified on the integration route, where the first Tab reaches it and Enter
lands on `#main` with its content clearing the bar.
### The breakpoint is genuinely configurable
A media query cannot read a runtime custom property, so this could easily have
been a prop that moved the JavaScript and left the layout behind. It is not:
the controller mirrors the breakpoint onto the root as `data-fp-mobile`, and
the layout keys off that attribute. The no-JS fallback, which does need a real
media query, is emitted per instance at the same value.
Audited with two instances on one page at different breakpoints — the default
1024 and a second at 900 — both correct at 1440, and independently correct when
the viewport crossed each one.
## Reduced-motion behaviour
Under `prefers-reduced-motion: reduce`, `--fp-duration` drops to `1ms`, and the
panel and sheet entrance animations with it. Every state change still happens
in full: measured 80ms after a scroll past the threshold, the bar had already
arrived at its final 896px, and a panel opened by Enter was `display: block` at
opacity 1 and full height with `animation-duration: 0.001s`.
Nothing is hidden to achieve that. Reduced motion removes the movement, not the
content.
## No-JavaScript behaviour
The controller adds `data-fp-ready` to the root, and every JS-driven state is
gated behind it. With JavaScript disabled:
- **above** the breakpoint you get the full bar, and panels open on `:hover`
and `:focus-within` — so a keyboard alone still opens every one of them,
verified by tabbing to a trigger and watching its panel appear;
- **at or below** it the bar stacks: the navigation and the actions are shown
in place beneath the brand row, panels open in the flow rather than as
popups, and the bar scrolls itself if it is taller than the screen without
ever touching the page's own scrolling — the scroll lock is controller-only;
- **there is no menu button** in either case, because without the controller it
could not open or close anything. A control that does nothing is worse than
no control.
Measured with scripting disabled: five visible links at 1440 and at 390, no
menu button at either, panels closed at rest and open on hover at both widths,
and no horizontal overflow.
That fallback is a real media query at the configured breakpoint, emitted per
instance in the markup and scoped to that instance's `data-fp-uid`. It is
delivered with **`set:text`**; the component uses **no `set:html` anywhere**. A
`<style>` element's text content is its stylesheet, so the safe text channel is
also the correct one — and because `set:text` escapes HTML while a `<style>`'s
contents are raw text the parser does not decode, the generated string contains
no quotes, angle brackets or ampersands. The uid is reduced to `[A-Za-z0-9_-]`
and forced to start with a letter, so an `id` containing quotes or braces
cannot break out of the selector.
## Multiple instances
Two or more on a page are independent:
- ids are minted per instance (`fp-xxxxxxx-panel-0`, `fp-xxxxxxx-menu`), or
seeded from `id` if you pass one — verified as zero duplicate ids and zero
broken `aria-controls` with two instances on a page;
- every query is scoped to the instance root, so opening one header's panel
leaves the other's closed;
- each instance keeps its own breakpoint, its own `currentPath` and its own
scroll state;
- the body scroll lock is **counted at module scope**, so the second
instance's close cannot release a lock the first is still holding —
verified as `hidden` with either sheet open, still `hidden` after closing one
of two, and released only when both are closed, with the page's scroll
position (250px) unchanged throughout.
## Astro navigation
Written for a project running `<ClientRouter />` as well as one that is not:
- boots on first load **and** on every `astro:page-load`, with the listener
registered once per module so it cannot accumulate;
- init is idempotent — a `data-fp-ready` flag means a second boot over the same
element is a no-op rather than a second set of listeners;
- every listener carries an `AbortController` signal, and `astro:before-swap`
closes the sheet and any open panel, releases the scroll lock, aborts the
controller and clears the ready flag.
Certified against a real `<ClientRouter />`: five link-click transitions plus
Back and Forward at both 1440 and 390, each one driven from **inside** the open
navigation — a panel row at desktop, and the sheet with an accordion open on a
phone, which is the journey that actually strands state. On every arrival a
`window` sentinel survived, Navigation Timing stayed at one entry with an
unchanged `startTime`, there was exactly one root, no duplicate ids, no broken
`aria-controls`, the sheet closed, every `aria-expanded` false and the body
unlocked. Live `document`/`window` listeners held at exactly one each of
`scroll`, `keydown`, `pointerdown` and `astro:before-swap` throughout, while the
aborted count grew by four per transition — 0, 4, 8, 12, 16, 20, 24, 28.
## Cloudflare / static compatibility
Builds to static HTML, CSS and one small JS module. It needs **no** SSR
adapter, API route, Pages Function, Worker, database, cookie, session or
environment variable, and makes no network request of its own — verified as
zero external requests and zero console errors on the preview, the integration
route and the router fixture. It works in `output: "static"` and deploys to
Cloudflare Pages as plain files.
## Copying it into another Astro project
Copy **`FloatingPanelHeader.astro`** into your components folder — in this
catalog it lives in `src/components/library/` because that is where the catalog
looks, but in a normal site `src/components/global/` is the more natural home.
Copy `FloatingPanelHeader.nav-data.ts` beside it and edit every label and
`href` to match routes that exist. Then render it in your shared layout:
```astro
---
// src/layouts/BaseLayout.astro
import Header from "../components/global/FloatingPanelHeader.astro";
import { primaryNav } from "../components/global/FloatingPanelHeader.nav-data";
---
<html lang="en">
<body>
<a class="skip" href="#main">Skip to main content</a>
<Header
items={primaryNav}
currentPath={Astro.url.pathname}
brandName="Your brand"
brandHref="/"
>
<svg slot="brand-mark" viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="8" fill="currentColor" />
</svg>
</Header>
<main id="main" tabindex="-1"><slot /></main>
</body>
</html>
```
Then two things the host page owes it:
**Reserve its height.** It is `position: fixed`, so it is out of the flow:
```css
main { padding-top: 5rem; } /* --fp-top + --fp-row + air */
:target, h1, h2 { scroll-margin-top: 6rem; } /* so hash targets clear it */
```
**Give it something to be legible against.** The bar is transparent until the
page scrolls, so at the top of a route it takes its colour from whatever is
behind it. Over a dark hero, set `ink` and `muted` to light values.
## Measurable differences from the reference
1. **An 8px gutter at every width.** The reference lets the bar reach the
viewport edges between 1024 and 1152px; this one keeps the same 8px gutter
it uses on a phone, so the bar is up to 16px narrower in that band and
identical everywhere else.
2. **Panels are centred under their trigger**, where the reference offsets each
one by a third of its own width. For the two panels it ships those land
within a few pixels of each other; centring cannot drift off the bar when a
panel is wider or narrower than theirs.
3. **Column widths are fixed at 272px (cards) and 236px (list)**, against the
reference's per-panel 288 and 240. A one-column panel is therefore 16px
narrower than theirs; a two-column panel is 514px against their 512px.
4. **The menu button carries `aria-expanded` and `aria-controls`.** The
reference's has neither — it is an `aria-label`-only button whose state is
invisible to assistive technology.
5. **The sheet traps focus, returns it, and closes on Escape.** The reference
does none of the three.
6. **The sheet locks body scroll.** The reference's full-screen overlay leaves
the page behind it scrolling.
7. **The panel footer link survives on a phone.** The reference drops it from
the mobile accordion, so "See the whole product" is unreachable there.
8. **Group headings survive on a phone** too, where the reference's accordion
shows only the links.
9. **`prefers-reduced-motion` is honoured.** The reference's 500ms morph and
200ms panel animations run regardless.
10. **A 56px bar against their 57.33px**, which is their 56px row plus a
hairline border this one does not draw.
11. **Different content, different icons, different mark.** Nothing of theirs
is reproduced: the icons are drawn in this file, the fallback mark is the
brand initial in a ruled square, and the demo copy is fictional.
## Known limitations
- **A mouse click on a hover-opened trigger closes its panel.** Hover opens it,
and the click then toggles — which matches the reference, and means a mouse
user's click never opens a panel that hovering has already opened. Keyboard
(Enter/Space) and touch both open on activation, because neither has
hovered first.
- **Panels are capped at 560px** and centred, which is what keeps them inside
the viewport at 1024px. Three columns of content will wrap rather than widen.
- **`backdrop-filter` is not universal.** Where it is unsupported the narrowed
bar and the sheet still get their fill, ring and shadow; only the blur is
missing. Raise `surface`'s alpha if that matters to you.
- **The bar is transparent at the top of a route.** That is the design, but it
means the header's legibility there is the page's responsibility, not the
component's.
- **No arrow-key navigation inside a panel.** It is a disclosure containing a
list of links, and Tab moves through them; it does not claim menu semantics.
===== END src/components/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.