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 "Expanding glass 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: Expanding glass header
Library page: https://astro.baysixmedia.com/components/expanding-glass-header/
Navigation mode: mega-menu — wide multi-column panels that open from their triggers
Position mode: sticky
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: public/expanding-glass-header/drill-decking.webp, public/expanding-glass-header/modern-house.webp, public/expanding-glass-header/white-house-lawn.webp
Prop interface, verbatim from the component:
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly ExpandingGlassHeaderItem[];
currentPath?: string;
cta?: { label: string; href: string } | null;
phone?: { number: string; label?: string } | null;
/** Pull the next section up under the header so the capsule floats over it (a hero). */
overlap?: boolean;
/** Tuck the capsule away on scroll down and bring it back on scroll up. */
hideOnScroll?: boolean;
/** "glass" floats translucent over a dark hero; "solid" is an opaque capsule that reads on any page. */
surface?: "glass" | "solid";
navLabel?: string;
menuLabel?: string;
closeLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
Route data is passed through props. The prop interface above is the shape to map the destination's routes into.
Known integration notes for this header:
- Put it first in <body> (after a skip link). It is position: sticky and reserves 84px (70px on phones); pass overlap to pull a dark hero up underneath it.
- Designed for a dark page start: the capsule is translucent glass. Over a light hero, set --egh-panel / --egh-ink or pick another header.
- Panels are the planner's tree in items[].groups / overview / features; a flat facts.links.primary list renders as plain links.
- Scripted: panels, the small-screen sheet and the tuck need JavaScript. Without it the plain links, the brand and the button still work, but sections with panels do not open.
- Written for <ClientRouter />: boots on astro:page-load, cleans up on astro:before-swap and counts its body scroll lock. Event-tested only; not yet run through a real router transition.
- Loads Geist and Geist Mono from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces (via --egh-family / --egh-mono).
--- EXPECTED BEHAVIOUR ---
Desktop: triggers are real <button> elements carrying aria-expanded and aria-controls; wide multi-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: sticky, so it holds its own space in the flow and no height needs reserving. It must not be placed inside an ancestor with overflow other than visible, or with a transform, filter or contain — any of those silently stop sticky working.
11. No layout height needs reserving for this one — say so explicitly rather than adding padding that would leave a gap.
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 sticky 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/ExpandingGlassHeader.astro =====
---
/**
* ExpandingGlassHeader (candidate, role `navigation`)
*
* A dark glass capsule floating at the top of the page that *becomes* its own dropdown. Hover or
* press a section and the capsule itself grows downward into a solid panel — no separate flyout —
* the chosen trigger fills with the accent, its chevron rolls from down to up, and the panel's
* height glides between sections as the pointer moves along the bar. Panels hold columns of rows
* (an optional graded icon tile, a title, one line under it), an optional lead "overview" row for
* the section's own page, and up to three feature cards with a photo. The primary button's label
* rolls up and is replaced by itself on hover. Scrolling down tucks the capsule away; scrolling up
* brings it back.
*
* Below the width at which one line holds brand, sections and button (computed from the labels, so
* the bar never wraps), the sections move into a sheet inside the same capsule: the capsule grows to
* the viewport, each section is an accordion, and the button follows the list.
*
* The phone rides in the bar: a 44px tap-to-call button, or the number itself beside the button from the
* width at which it fits; on the smallest phones the call button takes the button's place. A runtime
* check backs the estimate: if a site face sets wider than planned, the number folds back to the
* button, then the sections fold into the sheet — the bar never overlaps.
*
* Without JavaScript every section with a page is a plain link, and the menu is an anchor that opens
* the sheet with every accordion expanded.
*
* Truth rules. Only supplied links render. A section with no rows is a plain link. Feature cards
* render only with a title and a safe href; a photo only with a src. The call line and call button
* render only from a real number.
*
* Reads, never declares: --egh-accent, --egh-accent-ink, --egh-panel, --egh-card, --egh-ink,
* --egh-muted, --egh-family, --egh-mono, --egh-max, --egh-top.
*/
export type ExpandingGlassHeaderIcon =
| "flame"
| "snowflake"
| "fan"
| "wrench"
| "shield"
| "calendar"
| "home"
| "pin"
| "star"
| "users"
| "clipboard"
| "chat"
| "drop"
| "bolt"
| "leaf"
| "document";
export interface ExpandingGlassHeaderRow {
label: string;
href: string;
description?: string;
icon?: ExpandingGlassHeaderIcon;
external?: boolean;
}
export interface ExpandingGlassHeaderGroup {
heading?: string;
links: readonly ExpandingGlassHeaderRow[];
}
export interface ExpandingGlassHeaderFeature {
tag?: string;
title: string;
href: string;
image?: { src: string; alt: string } | null;
}
export interface ExpandingGlassHeaderItem {
label: string;
href?: string;
/** Wording for the lead row that links to the section's own page (`href`); the row renders whenever href does. */
overview?: { title?: string; description?: string } | null;
groups?: readonly ExpandingGlassHeaderGroup[];
featureHeading?: string;
features?: readonly ExpandingGlassHeaderFeature[];
}
interface Props {
brandName?: string;
brandHref?: string;
logo?: { src: string; alt: string; width?: number; height?: number } | null;
items?: readonly ExpandingGlassHeaderItem[];
currentPath?: string;
cta?: { label: string; href: string } | null;
phone?: { number: string; label?: string } | null;
/** Pull the next section up under the header so the capsule floats over it (a hero). */
overlap?: boolean;
/** Tuck the capsule away on scroll down and bring it back on scroll up. */
hideOnScroll?: boolean;
/** "glass" floats translucent over a dark hero; "solid" is an opaque capsule that reads on any page. */
surface?: "glass" | "solid";
navLabel?: string;
menuLabel?: string;
closeLabel?: string;
fontStylesheet?: string;
class?: string;
id?: string;
}
const {
brandName = "",
brandHref,
logo = null,
items = [],
currentPath,
cta = null,
phone = null,
overlap = false,
hideOnScroll = true,
surface = "glass",
navLabel = "Primary",
menuLabel = "Menu",
closeLabel = "Close menu",
fontStylesheet = "https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@500&display=swap",
class: className,
id,
} = Astro.props;
const safeHref = (href: string | undefined | null) => {
const h = (href ?? "").trim();
return /^(https?:\/\/|mailto:|tel:|#|\/(?!\/))/i.test(h) ? h : null;
};
const norm = (p: string) => p.replace(/\/+$/, "") || "/";
const isCurrent = (href: string | null) => Boolean(currentPath && href && norm(currentPath) === norm(href));
const ICONS: Record<ExpandingGlassHeaderIcon, string> = {
flame: "M12 3c.6 3 3.9 4.9 3.9 9a3.9 3.9 0 0 1-7.8 0c0-1.7.8-2.9 1.9-3.9.2 1.4.9 2.3 1.9 2.7C11.5 8.7 11 6 12 3Z M7.5 13.5A4.5 4.5 0 0 0 12 21a4.5 4.5 0 0 0 4.5-7.5",
snowflake: "M12 3v18 M4.2 7.5l15.6 9 M4.2 16.5l15.6-9 M9.5 4.5 12 6.5l2.5-2 M9.5 19.5 12 17.5l2.5 2",
fan: "M12 12c-1.5-3.5-1-7.5 2-8.5 2.5-.8 4 1.5 3 3.5-.9 1.8-3 3.4-5 5Z M12 12c3.7-.5 7.1 1.7 6.5 4.8-.5 2.6-3.2 2.8-4.5 1-1.2-1.6-1.6-4.1-2-5.8Z M12 12c-2.2 3-5.9 4.6-8.1 2.4-1.9-1.8-.6-4.2 1.6-4.3 2-.1 4.4.9 6.5 1.9Z M12 12h.01",
wrench: "M14.5 5.5a4 4 0 0 0 4.9 4.9L10 19.8a2.1 2.1 0 0 1-3-3l9.4-9.4a4 4 0 0 0-1.9-1.9Z M14.5 5.5 17 3l1.5 1.5L21 3v0l-1.5 2.5L21 7l-2.5 2.5",
shield: "M12 3 5 5.8v5.4c0 4.3 2.9 8.1 7 9.8 4.1-1.7 7-5.5 7-9.8V5.8L12 3Z M9 12l2 2 4-4",
calendar: "M5 6h14v14H5z M5 10h14 M9 3.5V7 M15 3.5V7 M9 14h2 M13 14h2 M9 17h2",
home: "M4 11 12 4l8 7 M6.5 9.5V20h11V9.5 M10 20v-5h4v5",
pin: "M12 21s-6.5-5.8-6.5-11a6.5 6.5 0 0 1 13 0c0 5.2-6.5 11-6.5 11Z M12 12.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z",
star: "m12 3.8 2.5 5.1 5.6.8-4 4 .9 5.5-5-2.6-5 2.6.9-5.5-4-4 5.6-.8L12 3.8Z",
users: "M9 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z M2.5 20c.6-3.4 3.2-5.5 6.5-5.5s5.9 2.1 6.5 5.5 M16 4.3a3.5 3.5 0 0 1 0 6.4 M18 14.8c2 .7 3.2 2.4 3.5 5.2",
clipboard: "M8 5H6v16h12V5h-2 M9 3.5h6v3H9z M9 11h6 M9 15h6 M9 18.5h3",
chat: "M4 5h16v11H9l-5 4V5Z M8 9.5h8 M8 12.5h5",
drop: "M12 3.5s6 6.3 6 10.5a6 6 0 0 1-12 0c0-4.2 6-10.5 6-10.5Z M9 14.5a3 3 0 0 0 3 3",
bolt: "M13 3 5 13.5h6L10.5 21 19 10h-6l0-7Z",
leaf: "M5 19c0-8 5-13.5 15-14-.4 9.5-5.5 14.5-13 14.5 M5 19c2.5-4 5.5-6.8 9-8.5",
document: "M6 3h8.5L19 7.5V21H6V3Z M14 3v5h5 M9 12h7 M9 15.5h7 M9 19h4",
};
/* Sections: a section with rows is a trigger; one without is a plain link. */
const sections = items
.map((item, i) => {
const label = (item?.label ?? "").trim();
const href = safeHref(item?.href);
const groups = (item?.groups ?? [])
.map((g) => {
const rows = (g?.links ?? [])
.map((r) => ({
label: (r?.label ?? "").trim(),
href: safeHref(r?.href),
description: (r?.description ?? "").trim(),
icon: r?.icon && ICONS[r.icon] ? r.icon : null,
external: Boolean(r?.external),
}))
.filter((r): r is typeof r & { href: string } => Boolean(r.label && r.href));
const tiled = rows.some((r) => r.icon);
/* The tiles step from dark to light down each column. */
return { heading: (g?.heading ?? "").trim(), tiled, rows: rows.map((r, j) => ({ ...r, shade: rows.length > 1 ? Math.round((j / (rows.length - 1)) * 100) : 0 })) };
})
.filter((g) => g.rows.length > 0);
const features = (item?.features ?? [])
.map((f) => ({ tag: (f?.tag ?? "").trim(), title: (f?.title ?? "").trim(), href: safeHref(f?.href), image: f?.image?.src ? { src: f.image.src, alt: f.image.alt ?? "" } : null }))
.filter((f): f is typeof f & { href: string } => Boolean(f.title && f.href))
.slice(0, 3);
/* The section's own page always leads its panel (a trigger is a button, not a link); overview only words it. */
const overview = href && (groups.length || features.length) ? { title: (item?.overview?.title ?? "").trim() || label, description: (item?.overview?.description ?? "").trim() } : null;
const hasPanel = groups.length > 0 || features.length > 0;
const panelHrefs = [href, ...groups.flatMap((g) => g.rows.map((r) => r.href)), ...features.map((f) => f.href)];
return {
key: `${id ?? "egh"}-s${i}`,
label,
href,
groups,
features,
featureHeading: (item?.featureHeading ?? "").trim(),
overview,
hasPanel,
current: panelHrefs.some((h) => isCurrent(h)),
};
})
.filter((s) => s.label && (s.hasPanel || s.href));
const action = cta && cta.label?.trim() && safeHref(cta.href) ? { label: cta.label.trim(), href: safeHref(cta.href)! } : null;
const digits = (phone?.number ?? "").replace(/[^\d+]/g, "");
const call = phone && phone.number?.trim() && digits.replace(/\D/g, "").length >= 7 ? { number: phone.number.trim(), label: (phone.label ?? "Call").trim(), href: `tel:${digits}` } : null;
const name = brandName.trim();
const home = safeHref(brandHref);
/*
* The one-line budget, in px at the default type (16px Geist sections; a 21/18/15.5px wordmark by the
* name's length), calibrated on the rendered bar: 8.4px per section character, 22px padding, 18px for a
* chevron, 8px between sections. The sections centre in the capsule while there is room and give up the
* centre before they give up a line, so the need is the plain sum. 5% margin for a wider site face.
*/
const nameSize = name.length <= 16 ? "l" : name.length <= 26 ? "m" : "s";
const perChar = { l: 10.6, m: 9.1, s: 7.9 }[nameSize];
const brandW = logo?.src ? Math.min(180, Math.round(((logo.width ?? 160) / (logo.height ?? 40)) * 26)) + 18 : Math.max(60, Math.min(name.length, nameSize === "s" ? 22 : 99) * perChar) + 18;
const navAt = (perLabel: number, pad: number, chev: number, gap: number) => sections.reduce((w, s) => w + s.label.length * perLabel + pad + (s.hasPanel ? chev : 0), 0) + Math.max(0, sections.length - 1) * gap;
const ctaW = action ? action.label.length * 8.3 + 52 : 0;
/* Compact density (15px sections, tighter padding) only when the regular bar would not fit even at 1440. */
const regular = navAt(8.4, 22, 18, 8);
const compact = navAt(7.9, 16, 16, 2);
const density = (brandW + regular + ctaW + 57 + (call ? 54 : 0)) * 1.05 > 1139 && (brandW + compact + ctaW + 57 + (call ? 54 : 0)) * 1.05 <= 1139 ? "compact" : "regular";
const navW = density === "compact" ? compact : regular;
/*
* The phone costs a 44px call button; the fuller call line ("Call (650) 555-0148") is used instead only
* when it also fits at the width the sections go inline — the sections never give way to it.
*/
const base = brandW + navW + ctaW + 32 + 25;
const need = (base + (call ? 54 : 0)) * 1.05;
/* Capsule content width at each allowed step: viewport − 40px frame − 25px capsule padding, capped at the 1164px capsule. */
const STEPS = [960, 1024, 1120, 1200, 1280, 1440] as const;
const inner = (w: number) => Math.min(w - 40, 1164) - 25;
const inlineAt = sections.length ? (STEPS.find((w) => inner(w) >= need) ?? "never") : 0;
/* The call line (glyph + number) replaces the call button from the first step at which it also fits. */
const lineW = call ? call.number.length * 7.6 + 36 : 0;
const lineAt = !call ? null : inlineAt === 0 ? 0 : inlineAt === "never" ? null : (STEPS.find((w) => w >= inlineAt && inner(w) >= (base + lineW) * 1.05) ?? null);
const hasSheet = sections.length > 0;
const sheetId = `${id ?? "egh"}-sheet`;
---
{(name || logo?.src || sections.length > 0) && (
<header
class:list={["egh", className]}
id={id}
data-inline={String(inlineAt)}
data-name={nameSize}
data-density={density}
data-overlap={overlap ? "" : undefined}
data-hide={hideOnScroll ? "" : undefined}
data-surface={surface === "solid" ? "solid" : "glass"}
data-call={call ? "" : undefined}
data-line={lineAt === null ? undefined : String(lineAt)}
>
{fontStylesheet && <link rel="stylesheet" href={fontStylesheet} />}
<div class="egh-frame">
<div class="egh-shell">
<span class="egh-stroke egh-stroke--top" aria-hidden="true"></span>
<span class="egh-stroke egh-stroke--bottom" aria-hidden="true"></span>
<div class="egh-bar">
{home ? (
<a class="egh-brand" href={home}>
{logo?.src ? <img class="egh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="egh-wordmark">{name}</span>}
</a>
) : (
<span class="egh-brand">
{logo?.src ? <img class="egh-logo" src={logo.src} alt={logo.alt || name} width={logo.width} height={logo.height} /> : <span class="egh-wordmark">{name}</span>}
</span>
)}
{sections.length > 0 && (
<nav class="egh-nav" aria-label={navLabel}>
<ul role="list">
{sections.map((s) => (
<li>
{s.hasPanel && s.href && (
<a class="egh-trigger egh-nojs" href={s.href} aria-current={isCurrent(s.href) ? "page" : undefined} data-current={s.current ? "" : undefined}>
<span>{s.label}</span>
</a>
)}
{s.hasPanel ? (
<button class:list={["egh-trigger", { "egh-js": Boolean(s.href) }]} type="button" aria-expanded="false" aria-controls={`${s.key}-panel`} data-section={s.key} data-current={s.current ? "" : undefined}>
<span>{s.label}</span>
<span class="egh-chev" aria-hidden="true">
<span class="egh-chev-track">
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M3 7.5 6 4.5l3 3" /></svg>
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M3 4.5 6 7.5l3-3" /></svg>
</span>
</span>
</button>
) : (
<a class="egh-trigger" href={s.href} aria-current={isCurrent(s.href) ? "page" : undefined}>
<span>{s.label}</span>
</a>
)}
</li>
))}
</ul>
</nav>
)}
<div class="egh-actions">
{call && (
<a class="egh-call-line" href={call.href}>
<svg aria-hidden="true" viewBox="0 0 20 20" width="16" height="16"><path d="M6.2 2.8 8 6.6 6.3 8.2c.9 2 2.5 3.6 4.5 4.5l1.6-1.7 3.8 1.8-.6 3.2c-.1.5-.5.8-1 .8C8.4 16.6 3.4 11.6 2.2 5.4c-.1-.5.3-.9.8-1Z" /></svg>
<span class="egh-sr">{call.label} </span>
<span class="egh-call-number">{call.number}</span>
</a>
)}
{call && (
<a class="egh-call-icon" href={call.href} aria-label={`${call.label} ${call.number}`}>
<svg aria-hidden="true" viewBox="0 0 20 20" width="18" height="18"><path d="M6.2 2.8 8 6.6 6.3 8.2c.9 2 2.5 3.6 4.5 4.5l1.6-1.7 3.8 1.8-.6 3.2c-.1.5-.5.8-1 .8C8.4 16.6 3.4 11.6 2.2 5.4c-.1-.5.3-.9.8-1Z" /></svg>
</a>
)}
{action && (
<a class="egh-cta" href={action.href}>
<span class="egh-roll">
<span>{action.label}</span>
<span aria-hidden="true">{action.label}</span>
</span>
</a>
)}
{hasSheet && (
<>
<span class="egh-divider" aria-hidden="true"></span>
<a class="egh-menu egh-nojs" href={`#${sheetId}`} aria-label={menuLabel}>
<span class="egh-burger" aria-hidden="true"><span></span><span></span><span></span></span>
</a>
<button class="egh-menu egh-js" type="button" aria-expanded="false" aria-controls={sheetId} data-open-label={closeLabel} data-closed-label={menuLabel} aria-label={menuLabel}>
<span class="egh-burger" aria-hidden="true"><span></span><span></span><span></span></span>
</button>
</>
)}
</div>
</div>
{/* Desktop panels share one stage; its height follows the open panel. */}
{sections.some((s) => s.hasPanel) && (
<div class="egh-stage">
{sections
.filter((s) => s.hasPanel)
.map((s) => (
<div class="egh-panel" id={`${s.key}-panel`} data-section={s.key} role="group" aria-label={s.label}>
<div class="egh-panel-grid" style={`--g:${s.groups.length || 0};--f:${s.features.length}`} data-f={String(s.features.length)} data-g={String(s.groups.length)}>
{(s.overview || s.groups.length > 0) && (
<div class="egh-groups">
{s.overview && (
<a class="egh-lead" href={s.href!} aria-current={isCurrent(s.href) ? "page" : undefined}>
<span class="egh-lead-tile" aria-hidden="true">
<svg viewBox="0 0 24 24" width="26" height="26"><path d="M4 12h16 M12 4v16 M6.3 6.3l11.4 11.4 M17.7 6.3 6.3 17.7" /><circle cx="12" cy="12" r="4.2" /></svg>
</span>
<span class="egh-row-text">
<span class="egh-row-title">
{s.overview.title}
<svg class="egh-lead-arrow" aria-hidden="true" viewBox="0 0 16 16" width="14" height="14"><path d="M3 8h9.5 M8.5 4l4 4-4 4" /></svg>
</span>
{s.overview.description && <span class="egh-row-desc">{s.overview.description}</span>}
</span>
</a>
)}
<div class="egh-columns">
{s.groups.map((g) => (
<div class="egh-group">
{g.heading && <p class="egh-heading">{g.heading}</p>}
<ul role="list" class:list={["egh-rows", { "egh-rows--tiled": g.tiled }]}>
{g.rows.map((r) => (
<li>
<a
class="egh-row"
href={r.href}
aria-current={isCurrent(r.href) ? "page" : undefined}
target={r.external ? "_blank" : undefined}
rel={r.external ? "noopener noreferrer" : undefined}
>
{g.tiled && (
<span class="egh-tile" style={`--t:${r.shade}%`} aria-hidden="true">
{r.icon && (
<svg viewBox="0 0 24 24" width="24" height="24">
<path d={ICONS[r.icon]} />
</svg>
)}
</span>
)}
<span class="egh-row-text">
<span class="egh-row-title">{r.label}</span>
{r.description && <span class="egh-row-desc">{r.description}</span>}
</span>
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{s.features.length > 0 && (
<div class="egh-features">
{s.featureHeading && <p class="egh-heading">{s.featureHeading}</p>}
<ul role="list" class="egh-cards">
{s.features.map((f) => (
<li>
<a class="egh-card" href={f.href} aria-current={isCurrent(f.href) ? "page" : undefined}>
<span class="egh-card-media">
{f.image ? <img src={f.image.src} alt={f.image.alt} loading="lazy" decoding="async" /> : <span class="egh-card-blank" aria-hidden="true"></span>}
{f.tag && <span class="egh-card-tag">{f.tag}</span>}
</span>
<span class="egh-card-title">{f.title}</span>
<svg class="egh-card-arrow" aria-hidden="true" viewBox="0 0 16 16" width="16" height="16"><path d="M3 8h9.5 M8.5 4l4 4-4 4" /></svg>
</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
</div>
)}
{/* Small screens: the same sections as accordions in a sheet inside the capsule. */}
{hasSheet && (
<div class="egh-drawer" id={sheetId}>
<nav class="egh-sheet" aria-label={navLabel}>
<ul role="list" class="egh-sheet-list">
{sections.map((s) => (
<li class="egh-acc">
{s.hasPanel ? (
<>
<button class="egh-acc-trigger" type="button" aria-expanded="false" aria-controls={`${s.key}-acc`} data-current={s.current ? "" : undefined}>
<span>{s.label}</span>
<span class="egh-chev" aria-hidden="true">
<span class="egh-chev-track">
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M3 7.5 6 4.5l3 3" /></svg>
<svg viewBox="0 0 12 12" width="12" height="12"><path d="M3 4.5 6 7.5l3-3" /></svg>
</span>
</span>
</button>
<div class="egh-acc-region" id={`${s.key}-acc`}>
<div class="egh-acc-inner">
{s.overview && (
<a class="egh-lead" href={s.href!} aria-current={isCurrent(s.href) ? "page" : undefined}>
<span class="egh-lead-tile" aria-hidden="true">
<svg viewBox="0 0 24 24" width="24" height="24"><path d="M4 12h16 M12 4v16 M6.3 6.3l11.4 11.4 M17.7 6.3 6.3 17.7" /><circle cx="12" cy="12" r="4.2" /></svg>
</span>
<span class="egh-row-text">
<span class="egh-row-title">{s.overview.title}</span>
{s.overview.description && <span class="egh-row-desc">{s.overview.description}</span>}
</span>
</a>
)}
{s.groups.map((g) => (
<div class="egh-group">
{g.heading && <p class="egh-heading">{g.heading}</p>}
<ul role="list" class:list={["egh-rows", { "egh-rows--tiled": g.tiled }]}>
{g.rows.map((r) => (
<li>
<a
class="egh-row"
href={r.href}
aria-current={isCurrent(r.href) ? "page" : undefined}
target={r.external ? "_blank" : undefined}
rel={r.external ? "noopener noreferrer" : undefined}
>
{g.tiled && (
<span class="egh-tile" style={`--t:${r.shade}%`} aria-hidden="true">
{r.icon && (
<svg viewBox="0 0 24 24" width="22" height="22">
<path d={ICONS[r.icon]} />
</svg>
)}
</span>
)}
<span class="egh-row-text">
<span class="egh-row-title">{r.label}</span>
{r.description && <span class="egh-row-desc">{r.description}</span>}
</span>
</a>
</li>
))}
</ul>
</div>
))}
{s.features.length > 0 && (
<div class="egh-group">
{s.featureHeading && <p class="egh-heading">{s.featureHeading}</p>}
<ul role="list" class="egh-rows">
{s.features.map((f) => (
<li>
<a class="egh-row egh-row--feature" href={f.href}>
<span class="egh-row-text">
{f.tag && <span class="egh-row-tag">{f.tag}</span>}
<span class="egh-row-title">{f.title}</span>
</span>
</a>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</>
) : (
<a class="egh-acc-trigger" href={s.href} aria-current={isCurrent(s.href) ? "page" : undefined}>
<span>{s.label}</span>
</a>
)}
</li>
))}
</ul>
{(action || call) && (
<div class="egh-sheet-foot">
{action && (
<a class="egh-cta egh-cta--block" href={action.href}>
<span class="egh-roll">
<span>{action.label}</span>
<span aria-hidden="true">{action.label}</span>
</span>
</a>
)}
{call && (
<a class="egh-call" href={call.href}>
<span class="egh-call-label">{call.label}</span>
<span>{call.number}</span>
</a>
)}
</div>
)}
</nav>
</div>
)}
</div>
</div>
</header>
)}
<style>
.egh {
--egh-a: var(--egh-accent, #d98f5b);
--egh-a-ink: var(--egh-accent-ink, #121213);
--egh-solid: var(--egh-panel, #1d1d1f);
--egh-raised: var(--egh-card, #29292c);
--egh-text: var(--egh-ink, #ffffff);
--egh-soft: var(--egh-muted, #9ba6b4);
--egh-font: var(--egh-family, "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif);
--egh-font-mono: var(--egh-mono, "Geist Mono", ui-monospace, "SFMono-Regular", Menlo, monospace);
--egh-cap: var(--egh-max, 1164px);
--egh-pad-top: var(--egh-top, 30px);
--egh-pad-x: 20px;
--egh-bar-h: 54px;
--egh-ease: cubic-bezier(0.32, 0.72, 0, 1);
--egh-stage-h: 0px;
--egh-drawer-h: 0px;
position: sticky;
top: 0;
z-index: 60;
height: calc(var(--egh-pad-top) + var(--egh-bar-h));
font-family: var(--egh-font);
color: var(--egh-text);
transition: transform 420ms var(--egh-ease);
}
.egh[data-overlap] {
margin-bottom: calc(-1 * (var(--egh-pad-top) + var(--egh-bar-h)));
}
.egh.is-tucked {
transform: translateY(calc(-100% - 12px));
}
.egh-frame {
position: relative;
height: 100%;
padding: var(--egh-pad-top) var(--egh-pad-x) 0;
box-sizing: border-box;
}
/* The capsule: glass at rest, solid once it opens. It grows downward over the page. */
.egh-shell {
position: absolute;
top: var(--egh-pad-top);
left: var(--egh-pad-x);
right: var(--egh-pad-x);
max-width: var(--egh-cap);
margin-inline: auto;
border-radius: 12px;
/* Smoked glass: dark enough that white labels hold over a bright photo, clear enough to show the hero. */
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 70%), rgba(22, 22, 26, 0.52);
-webkit-backdrop-filter: blur(14px) saturate(1.3);
backdrop-filter: blur(14px) saturate(1.3);
isolation: isolate;
transition:
background-color 380ms var(--egh-ease),
box-shadow 380ms var(--egh-ease);
}
/* Solid: opaque from the start, for a page whose first section is light. */
.egh[data-surface="solid"] .egh-shell {
background: var(--egh-solid);
box-shadow: 0 10px 30px -14px rgba(0, 0, 0, 0.45);
}
.egh.is-open .egh-shell {
background: var(--egh-solid);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.65);
}
/* Two hairlines, each fading out toward the other edge: light at the top, a paler line at the bottom. */
.egh-stroke {
position: absolute;
inset: 0;
border-radius: inherit;
border: 1px solid;
pointer-events: none;
z-index: 2;
}
.egh-stroke--top {
border-color: rgba(255, 255, 255, 0.34);
-webkit-mask-image: linear-gradient(0deg, transparent 10%, #000 100%);
mask-image: linear-gradient(0deg, transparent 10%, #000 100%);
}
.egh-stroke--bottom {
border-color: rgba(255, 255, 255, 0.12);
-webkit-mask-image: linear-gradient(180deg, transparent 30%, #000 100%);
mask-image: linear-gradient(180deg, transparent 30%, #000 100%);
}
/* Bar: brand · sections (centred) · button. */
.egh-bar {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
height: var(--egh-bar-h);
padding: 7px;
padding-left: 18px;
box-sizing: border-box;
}
.egh-brand {
display: inline-flex;
align-items: center;
justify-self: start;
min-height: 40px;
min-width: 0;
color: var(--egh-text);
text-decoration: none;
}
.egh-wordmark {
font-size: 21px;
font-weight: 500;
letter-spacing: -0.035em;
line-height: 1;
white-space: nowrap;
}
.egh[data-name="m"] .egh-wordmark {
font-size: 18px;
}
.egh[data-name="s"] .egh-wordmark {
font-size: 15.5px;
white-space: normal;
line-height: 1.1;
}
.egh-logo {
display: block;
height: 26px;
width: auto;
max-width: 180px;
object-fit: contain;
}
.egh-nav {
display: none;
}
.egh[data-density="compact"] .egh-nav ul {
gap: 2px;
}
.egh[data-density="compact"] .egh-trigger {
padding-inline: 8px;
font-size: 15px;
}
.egh-nav ul {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.egh-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 6px 11px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--egh-text);
font: inherit;
font-size: 16px;
line-height: 22px;
letter-spacing: -0.02em;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition:
background-color 300ms var(--egh-ease),
color 300ms var(--egh-ease);
}
a.egh-trigger:hover {
background: rgba(255, 255, 255, 0.08);
}
.egh-trigger[aria-expanded="true"] {
background: var(--egh-a);
color: var(--egh-a-ink);
}
/* The section holding the current page: a small accent dot under its label. */
.egh-trigger {
position: relative;
}
.egh-trigger[aria-current="page"]::after,
.egh-trigger[data-current]::after {
content: "";
position: absolute;
left: 50%;
bottom: -3px;
width: 4px;
height: 4px;
margin-left: -2px;
border-radius: 50%;
background: var(--egh-a);
transition: opacity 200ms linear;
}
.egh-trigger[aria-expanded="true"]::after {
opacity: 0;
}
/* Chevron roll: the up chevron waits above the clip and slides in as the down one leaves. */
.egh-chev {
display: inline-block;
width: 12px;
height: 12px;
overflow: hidden;
flex: none;
}
.egh-chev-track {
display: flex;
flex-direction: column;
transform: translateY(-12px);
transition: transform 380ms var(--egh-ease);
}
.egh-chev svg {
display: block;
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.3;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.9;
}
[aria-expanded="true"] > .egh-chev .egh-chev-track {
transform: translateY(0);
}
.egh-actions {
display: flex;
align-items: center;
justify-content: flex-end;
justify-self: end;
gap: 10px;
min-width: 0;
}
/* Phone: a quiet call line once the sections are inline; a tap-to-call button below that. */
.egh-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.egh-call-line {
display: none;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 8px;
color: var(--egh-text);
font-size: 15px;
letter-spacing: -0.01em;
text-decoration: none;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.egh-call-line svg {
flex: none;
fill: none;
stroke: var(--egh-a);
stroke-width: 1.6;
stroke-linejoin: round;
}
.egh-call-line:hover .egh-call-number {
text-decoration: underline;
text-underline-offset: 4px;
}
.egh-call-icon {
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
margin-block: -2px;
border-radius: 8px;
color: var(--egh-text);
transition: background-color 220ms linear;
}
.egh-call-icon:hover {
background: rgba(255, 255, 255, 0.1);
}
.egh-call-icon svg {
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linejoin: round;
}
/* Primary button: the label rolls up and is replaced by itself. */
.egh-cta {
display: inline-flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0 26px;
border-radius: 8px;
background: #ffffff;
color: #111112;
font-size: 16px;
letter-spacing: -0.02em;
text-decoration: none;
white-space: nowrap;
transition: background-color 380ms var(--egh-ease);
}
.egh-roll {
display: flex;
flex-direction: column;
gap: 8px;
height: 22px;
line-height: 22px;
overflow: hidden;
}
.egh-roll > span {
display: block;
transition: transform 420ms var(--egh-ease);
}
.egh-cta:hover {
background: #f2f2f2;
}
.egh-cta:hover .egh-roll > span {
transform: translateY(-30px);
}
.egh-divider {
width: 1px;
height: 36px;
background: rgba(255, 255, 255, 0.16);
}
.egh-menu {
position: relative;
display: inline-grid;
place-items: center;
width: 44px;
height: 44px;
margin-block: -2px;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--egh-text);
cursor: pointer;
}
.egh-burger {
position: relative;
width: 20px;
height: 14px;
}
.egh-burger span {
position: absolute;
left: 0;
width: 20px;
height: 1.5px;
border-radius: 1px;
background: currentColor;
transition:
transform 360ms var(--egh-ease),
opacity 200ms linear,
top 360ms var(--egh-ease);
}
.egh-burger span:nth-child(1) {
top: 0;
}
.egh-burger span:nth-child(2) {
top: 6.25px;
}
.egh-burger span:nth-child(3) {
top: 12.5px;
}
.egh-menu[aria-expanded="true"] .egh-burger span:nth-child(1) {
top: 6.25px;
transform: rotate(45deg) scaleX(1.12);
}
.egh-menu[aria-expanded="true"] .egh-burger span:nth-child(2) {
opacity: 0;
}
.egh-menu[aria-expanded="true"] .egh-burger span:nth-child(3) {
top: 6.25px;
transform: rotate(-45deg) scaleX(1.12);
}
/* Stage: the panels overlap in place; its height follows the open one, so switching sections glides. */
.egh-stage {
display: none;
position: relative;
height: var(--egh-stage-h);
overflow: hidden;
transition: height 440ms var(--egh-ease);
}
.egh-stage::before {
content: "";
position: absolute;
inset: 0 0 auto;
height: 1px;
background: #2f2f32;
opacity: 0;
transition: opacity 200ms linear;
}
.egh.is-open .egh-stage::before {
opacity: 1;
}
.egh-panel {
position: absolute;
inset: 0 0 auto;
max-height: calc(100dvh - var(--egh-pad-top) - var(--egh-bar-h) - 24px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: #3a3a3f transparent;
padding: 20px 18px 22px;
box-sizing: border-box;
visibility: hidden;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 200ms linear,
transform 300ms var(--egh-ease),
visibility 0s linear 200ms;
}
.egh-panel.is-active {
visibility: visible;
opacity: 1;
transform: none;
transition:
opacity 260ms linear 90ms,
transform 420ms var(--egh-ease) 60ms,
visibility 0s;
}
.egh-panel-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 24px;
}
.egh-panel-grid[data-f="1"] {
grid-template-columns: minmax(0, 1fr) minmax(240px, 290px);
}
.egh-panel-grid[data-f="2"] {
grid-template-columns: minmax(0, 1fr) minmax(0, 588px);
}
.egh-panel-grid[data-f="3"] {
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.9fr);
}
.egh-panel-grid[data-g="0"] {
grid-template-columns: minmax(0, 1fr);
}
.egh-groups {
display: grid;
gap: 22px;
align-content: start;
}
.egh-columns {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 24px;
}
.egh-heading {
margin: 0 0 20px;
color: var(--egh-soft);
font-family: var(--egh-font-mono);
font-size: 12px;
font-weight: 500;
line-height: 1.1;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.egh-rows {
display: grid;
gap: 20px;
margin: 0;
padding: 0;
list-style: none;
}
.egh-rows--tiled {
gap: 20px;
}
.egh-row,
.egh-lead {
position: relative;
display: flex;
align-items: center;
gap: 15px;
color: var(--egh-text);
text-decoration: none;
isolation: isolate;
}
/* Hover wash: a raised pill that reaches 8px past the row on every side. */
.egh-row::before {
content: "";
position: absolute;
inset: -8px;
z-index: -1;
border-radius: 8px;
background: var(--egh-raised);
opacity: 0;
transition: opacity 220ms linear;
}
.egh-row:hover::before,
.egh-row:focus-visible::before {
opacity: 1;
}
.egh-row-text {
display: grid;
gap: 4px;
min-width: 0;
}
.egh-row-title {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
line-height: 22px;
letter-spacing: -0.02em;
}
.egh-row-desc {
color: var(--egh-soft);
font-size: 14px;
line-height: 21px;
}
.egh-row-tag {
color: var(--egh-soft);
font-family: var(--egh-font-mono);
font-size: 11px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.egh-row[aria-current="page"] .egh-row-title,
.egh-lead[aria-current="page"] .egh-row-title {
color: var(--egh-a);
}
.egh-tile {
display: grid;
place-items: center;
flex: none;
width: 58px;
height: 58px;
border-radius: 6px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.07), transparent 60%),
color-mix(in srgb, color-mix(in srgb, var(--egh-a) 12%, #6b7684) var(--t, 0%), color-mix(in srgb, var(--egh-a) 16%, #3a4049));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
color: #ffffff;
}
.egh-tile svg,
.egh-lead-tile svg {
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Lead row: the section's own page, raised across the columns, with the accent tile. */
.egh-lead {
padding: 14px;
border-radius: 6px;
background: var(--egh-raised);
transition: background-color 220ms linear;
}
.egh-lead:hover {
background: #313135;
}
/* Title only: a slimmer lead row. */
.egh-lead:not(:has(.egh-row-desc)) {
padding: 10px 14px 10px 10px;
}
.egh-lead:not(:has(.egh-row-desc)) .egh-lead-tile {
width: 40px;
height: 40px;
}
.egh-lead-tile {
display: grid;
place-items: center;
flex: none;
width: 58px;
height: 58px;
border-radius: 6px;
background: var(--egh-a);
color: #ffffff;
}
.egh-lead-arrow {
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.6;
transition:
transform 320ms var(--egh-ease),
opacity 220ms linear;
}
.egh-lead:hover .egh-lead-arrow {
opacity: 1;
transform: translateX(3px);
}
/* Feature cards: photo, optional tag, title, arrow. */
.egh-cards {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 16px;
margin: 0;
padding: 0;
list-style: none;
}
.egh-card {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
padding: 4px;
border-radius: 6px;
background: var(--egh-raised);
color: var(--egh-text);
text-decoration: none;
box-sizing: border-box;
}
.egh-card-media {
position: relative;
display: block;
aspect-ratio: 16 / 9;
border-radius: 2px;
overflow: hidden;
background: #111112;
}
.egh-card-media img,
.egh-card-blank {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 600ms var(--egh-ease);
}
.egh-card-blank {
background:
radial-gradient(120% 90% at 0% 50%, color-mix(in srgb, var(--egh-a) 55%, transparent), transparent 60%),
#111112;
}
.egh-card:hover .egh-card-media img,
.egh-card:hover .egh-card-blank {
transform: scale(1.05);
}
.egh-card-tag {
position: absolute;
top: 12px;
left: 12px;
padding: 6px 8px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.8);
-webkit-backdrop-filter: blur(6px);
backdrop-filter: blur(6px);
color: #111112;
font-family: var(--egh-font-mono);
font-size: 11px;
font-weight: 500;
line-height: 14px;
letter-spacing: 0.02em;
text-transform: uppercase;
}
.egh-card-title {
display: block;
padding: 40px 40px 12px 8px;
margin-top: auto;
font-size: 16px;
line-height: 22.4px;
letter-spacing: -0.02em;
}
.egh-card-arrow {
position: absolute;
right: 14px;
bottom: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.4;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.5;
transition:
opacity 220ms linear,
transform 320ms var(--egh-ease);
}
.egh-card:hover .egh-card-arrow,
.egh-card:focus-visible .egh-card-arrow {
opacity: 1;
transform: translateX(2px);
}
/* Small screens: the capsule grows into a sheet, clipped to the viewport, scrolling inside. */
.egh-drawer {
position: relative;
height: var(--egh-drawer-h);
overflow: hidden;
transition: height 460ms var(--egh-ease);
}
.egh-sheet {
max-height: calc(100dvh - var(--egh-pad-top) - var(--egh-bar-h) - 16px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: #3a3a3f transparent;
padding: 18px 16px 20px;
box-sizing: border-box;
visibility: hidden;
transition: visibility 0s linear 460ms;
}
.egh.is-sheet .egh-sheet {
visibility: visible;
transition: none;
}
.egh-sheet-list {
display: grid;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.egh-acc-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 6px 11px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--egh-text);
font: inherit;
font-size: 16px;
letter-spacing: -0.02em;
text-decoration: none;
cursor: pointer;
transition:
background-color 300ms var(--egh-ease),
color 300ms var(--egh-ease);
}
.egh-acc-trigger[aria-expanded="true"] {
background: var(--egh-a);
color: var(--egh-a-ink);
}
.egh-acc-trigger[aria-current="page"]::after,
.egh-acc-trigger[data-current]:not([aria-expanded="true"])::after {
content: "";
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--egh-a);
}
.egh-acc-region {
display: grid;
grid-template-rows: 0fr;
visibility: hidden;
transition:
grid-template-rows 420ms var(--egh-ease),
visibility 0s linear 420ms;
}
.egh-acc-region.is-open {
grid-template-rows: 1fr;
visibility: visible;
transition: grid-template-rows 420ms var(--egh-ease);
}
.egh-acc-inner {
display: grid;
gap: 26px;
min-height: 0;
overflow: hidden;
padding: 0 0 0 0;
}
.egh-acc-region.is-open .egh-acc-inner {
padding: 16px 8px 18px;
}
.egh-acc-inner .egh-heading {
margin-bottom: 18px;
}
.egh-acc-inner .egh-lead {
margin-inline: -8px;
}
.egh-sheet-foot {
display: grid;
gap: 14px;
margin-top: 18px;
padding-top: 18px;
border-top: 1px solid #2f2f32;
}
.egh-cta--block {
width: 100%;
height: 48px;
box-sizing: border-box;
}
.egh-call {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
color: var(--egh-text);
font-size: 16px;
font-variant-numeric: tabular-nums;
text-decoration: none;
}
.egh-call-label {
color: var(--egh-soft);
font-size: 14px;
}
/* Focus: a visible accent ring on every control. */
.egh-brand:focus-visible,
.egh-trigger:focus-visible,
.egh-cta:focus-visible,
.egh-menu:focus-visible,
.egh-acc-trigger:focus-visible,
.egh-lead:focus-visible,
.egh-card:focus-visible,
.egh-call:focus-visible,
.egh-call-line:focus-visible,
.egh-call-icon:focus-visible {
outline: 2px solid var(--egh-a);
outline-offset: 2px;
}
.egh-row:focus-visible {
outline: none;
}
.egh-row:focus-visible::before {
box-shadow: 0 0 0 2px var(--egh-a);
}
/* Narrow phones: the bar keeps brand and menu; the button lives at the foot of the sheet. */
@media (max-width: 809.98px) {
.egh {
--egh-pad-top: var(--egh-top, 16px);
--egh-pad-x: 12px;
}
.egh-bar {
padding-left: 14px;
}
}
/* Smallest phones: with a number, the call button takes the button's place (the button leads the sheet foot). */
@media (max-width: 419.98px) {
.egh[data-call] .egh-actions > .egh-cta {
display: none;
}
.egh:not([data-call]) .egh-actions > .egh-cta {
height: 38px;
padding: 0 16px;
font-size: 15px;
}
.egh-actions {
gap: 6px;
}
}
/* Phones: a long name may take two lines rather than run under the menu. */
@media (max-width: 479.98px) {
.egh-wordmark {
font-size: 17px;
white-space: normal;
line-height: 1.1;
}
.egh[data-name="m"] .egh-wordmark,
.egh[data-name="s"] .egh-wordmark {
font-size: 15.5px;
}
}
/* Inline sections from the computed width; below it the sheet. */
@media (min-width: 960px) {
.egh[data-inline="960"] .egh-nav,
.egh[data-inline="960"] .egh-stage {
display: block;
}
.egh[data-inline="960"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="960"] .egh-divider,
.egh[data-inline="960"] .egh-menu,
.egh[data-inline="960"] .egh-drawer,
.egh[data-line="960"] .egh-call-icon {
display: none;
}
.egh[data-line="960"] .egh-call-line {
display: inline-flex;
}
}
@media (min-width: 1024px) {
.egh[data-inline="1024"] .egh-nav,
.egh[data-inline="1024"] .egh-stage {
display: block;
}
.egh[data-inline="1024"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="1024"] .egh-divider,
.egh[data-inline="1024"] .egh-menu,
.egh[data-inline="1024"] .egh-drawer,
.egh[data-line="1024"] .egh-call-icon {
display: none;
}
.egh[data-line="1024"] .egh-call-line {
display: inline-flex;
}
}
@media (min-width: 1120px) {
.egh[data-inline="1120"] .egh-nav,
.egh[data-inline="1120"] .egh-stage {
display: block;
}
.egh[data-inline="1120"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="1120"] .egh-divider,
.egh[data-inline="1120"] .egh-menu,
.egh[data-inline="1120"] .egh-drawer,
.egh[data-line="1120"] .egh-call-icon {
display: none;
}
.egh[data-line="1120"] .egh-call-line {
display: inline-flex;
}
}
@media (min-width: 1200px) {
.egh[data-inline="1200"] .egh-nav,
.egh[data-inline="1200"] .egh-stage {
display: block;
}
.egh[data-inline="1200"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="1200"] .egh-divider,
.egh[data-inline="1200"] .egh-menu,
.egh[data-inline="1200"] .egh-drawer,
.egh[data-line="1200"] .egh-call-icon {
display: none;
}
.egh[data-line="1200"] .egh-call-line {
display: inline-flex;
}
}
@media (min-width: 1280px) {
.egh[data-inline="1280"] .egh-nav,
.egh[data-inline="1280"] .egh-stage {
display: block;
}
.egh[data-inline="1280"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="1280"] .egh-divider,
.egh[data-inline="1280"] .egh-menu,
.egh[data-inline="1280"] .egh-drawer,
.egh[data-line="1280"] .egh-call-icon {
display: none;
}
.egh[data-line="1280"] .egh-call-line {
display: inline-flex;
}
}
@media (min-width: 1440px) {
.egh[data-inline="1440"] .egh-nav,
.egh[data-inline="1440"] .egh-stage {
display: block;
}
.egh[data-inline="1440"] .egh-bar {
grid-template-columns: 1fr auto 1fr;
gap: 16px;
}
.egh[data-inline="1440"] .egh-divider,
.egh[data-inline="1440"] .egh-menu,
.egh[data-inline="1440"] .egh-drawer,
.egh[data-line="1440"] .egh-call-icon {
display: none;
}
.egh[data-line="1440"] .egh-call-line {
display: inline-flex;
}
}
/* No sections at all: just brand and button. */
.egh[data-inline="0"] .egh-divider,
.egh[data-inline="0"] .egh-menu,
.egh[data-line="0"] .egh-call-icon {
display: none;
}
.egh[data-line="0"] .egh-call-line {
display: inline-flex;
}
/* Runtime guard (set by the script when a wider face would crowd the bar): number first, then sections. */
.egh.is-tight-call .egh-call-line {
display: none;
}
.egh.is-tight-call[data-call] .egh-call-icon {
display: inline-grid;
}
.egh.is-crowded .egh-bar {
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
}
.egh.is-crowded .egh-nav,
.egh.is-crowded .egh-stage,
.egh.is-crowded .egh-call-line {
display: none;
}
.egh.is-crowded .egh-divider,
.egh.is-crowded .egh-drawer {
display: block;
}
.egh.is-crowded .egh-menu.egh-js,
.egh.is-crowded[data-call] .egh-call-icon {
display: inline-grid;
}
/* Progressive enhancement: the no-JS twins give way once the script runs. */
.egh:not(.is-enhanced) .egh-js,
.egh.is-enhanced .egh-nojs {
display: none !important;
}
.egh:not(.is-enhanced) .egh-drawer:target {
height: auto;
}
.egh:not(.is-enhanced):has(.egh-drawer:target) .egh-shell {
background: var(--egh-solid);
}
.egh:not(.is-enhanced) .egh-drawer:target .egh-sheet {
visibility: visible;
transition: none;
}
.egh:not(.is-enhanced) .egh-acc-region {
grid-template-rows: 1fr;
visibility: visible;
}
.egh:not(.is-enhanced) .egh-acc-inner {
padding: 12px 8px 18px;
}
.egh:not(.is-enhanced) .egh-acc-trigger .egh-chev {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.egh,
.egh *,
.egh *::before {
transition-duration: 0s !important;
transition-delay: 0s !important;
}
.egh-cta:hover .egh-roll > span {
transform: none;
}
.egh-card:hover .egh-card-media img,
.egh-card:hover .egh-card-blank {
transform: none;
}
}
</style>
<script>
/**
* One controller per header, one boot per page view. Survives <ClientRouter />: boots on first load
* and on astro:page-load, init is idempotent, every listener hangs off an AbortController aborted on
* astro:before-swap, and the body scroll lock is counted at module scope.
*/
let locks = 0;
let savedOverflow = "";
let savedPadding = "";
function lockScroll() {
if (locks++ > 0) return;
savedOverflow = document.body.style.overflow;
savedPadding = document.body.style.paddingRight;
const gap = window.innerWidth - document.documentElement.clientWidth;
if (gap > 0) document.body.style.paddingRight = `${gap}px`;
document.body.style.overflow = "hidden";
}
function releaseScroll() {
if (locks === 0 || --locks > 0) return;
document.body.style.overflow = savedOverflow;
document.body.style.paddingRight = savedPadding;
}
const FOCUSABLE = "a[href], button:not([disabled])";
function init(root: HTMLElement) {
if (root.dataset.eghReady) return;
root.dataset.eghReady = "1";
root.classList.add("is-enhanced");
const controller = new AbortController();
const { signal } = controller;
const shell = root.querySelector<HTMLElement>(".egh-shell")!;
const stage = root.querySelector<HTMLElement>(".egh-stage");
const triggers = [...root.querySelectorAll<HTMLButtonElement>("button.egh-trigger")];
const barLinks = [...root.querySelectorAll<HTMLElement>(".egh-nav .egh-trigger:not(.egh-nojs), .egh-actions > .egh-cta")];
const menu = root.querySelector<HTMLButtonElement>("button.egh-menu");
const drawer = root.querySelector<HTMLElement>(".egh-drawer");
const sheet = root.querySelector<HTMLElement>(".egh-sheet");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
const finePointer = window.matchMedia("(hover: hover) and (pointer: fine)");
/* ---------- Desktop panels ---------- */
let active: HTMLButtonElement | null = null;
let closeTimer = 0;
/* A click right after a hover opened the panel confirms it rather than closing it. */
let hoverOpenedAt = 0;
const panelOf = (t: HTMLButtonElement) => root.querySelector<HTMLElement>(`#${CSS.escape(t.getAttribute("aria-controls") ?? "")}`);
const measure = () => {
if (!stage) return;
const panel = active ? panelOf(active) : null;
stage.style.setProperty("--egh-stage-h", panel ? `${panel.offsetHeight}px` : "0px");
};
const resize = new ResizeObserver(() => measure());
function open(t: HTMLButtonElement | null) {
window.clearTimeout(closeTimer);
if (t === active) return;
if (active) {
active.setAttribute("aria-expanded", "false");
const p = panelOf(active);
p?.classList.remove("is-active");
if (p) resize.unobserve(p);
}
active = t;
if (t) {
t.setAttribute("aria-expanded", "true");
const p = panelOf(t);
p?.classList.add("is-active");
if (p) resize.observe(p);
}
root.classList.toggle("is-open", Boolean(t) || sheetOpen);
measure();
if (t) reveal();
}
const close = () => open(null);
const scheduleClose = () => {
window.clearTimeout(closeTimer);
closeTimer = window.setTimeout(close, 160);
};
/* Hover intent: from rest the pointer must dwell 110ms on a section (a pass across the bar opens nothing); once open, sections switch at once. */
let intentTimer = 0;
const hoverOpen = (t: HTMLButtonElement) => {
open(t);
hoverOpenedAt = performance.now();
};
for (const t of triggers) {
t.addEventListener(
"pointerenter",
(e) => {
if (e.pointerType !== "mouse" || !finePointer.matches || active === t) return;
window.clearTimeout(intentTimer);
if (active) hoverOpen(t);
else intentTimer = window.setTimeout(() => hoverOpen(t), 110);
},
{ signal },
);
t.addEventListener("pointerleave", () => window.clearTimeout(intentTimer), { signal });
t.addEventListener(
"click",
() => {
if (active === t && performance.now() - hoverOpenedAt < 600) return;
open(active === t ? null : t);
},
{ signal },
);
t.addEventListener(
"keydown",
(e) => {
const panel = panelOf(t);
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
if (e.key === "ArrowDown") {
e.preventDefault();
open(t);
first?.focus();
} else if (e.key === "Tab" && !e.shiftKey && active === t && first) {
/* The panel sits after the bar in the DOM: step into it from its own trigger. */
e.preventDefault();
first.focus();
}
},
{ signal },
);
}
/* Plain links on the bar close any open panel when hovered. */
for (const a of root.querySelectorAll<HTMLElement>("a.egh-trigger, .egh-actions > .egh-cta")) {
a.addEventListener(
"pointerenter",
(e) => {
if (e.pointerType === "mouse") scheduleClose();
},
{ signal },
);
}
shell.addEventListener(
"pointerleave",
(e) => {
if (e.pointerType === "mouse" && active) scheduleClose();
},
{ signal },
);
shell.addEventListener("pointerenter", () => window.clearTimeout(closeTimer), { signal });
/* Inside a panel: Tab past the last link continues along the bar; Shift+Tab before the first returns to the trigger. */
stage?.addEventListener(
"keydown",
(e) => {
if (!active || e.key !== "Tab") return;
const panel = panelOf(active);
const items = panel ? [...panel.querySelectorAll<HTMLElement>(FOCUSABLE)] : [];
const i = items.indexOf(document.activeElement as HTMLElement);
if (!e.shiftKey && i === items.length - 1) {
e.preventDefault();
const next = barLinks[barLinks.indexOf(active) + 1];
close();
next?.focus();
} else if (e.shiftKey && i === 0) {
e.preventDefault();
active.focus();
}
},
{ signal },
);
/* ---------- Small-screen sheet ---------- */
let sheetOpen = false;
const sizeDrawer = () => {
if (!drawer || !sheet) return;
drawer.style.setProperty("--egh-drawer-h", sheetOpen ? `${sheet.offsetHeight}px` : "0px");
};
const sheetResize = new ResizeObserver(() => sizeDrawer());
if (sheet) sheetResize.observe(sheet);
function setSheet(next: boolean, restoreFocus = true) {
if (!menu || next === sheetOpen) return;
sheetOpen = next;
menu.setAttribute("aria-expanded", String(next));
menu.setAttribute("aria-label", (next ? menu.dataset.openLabel : menu.dataset.closedLabel) ?? "");
root.classList.toggle("is-sheet", next);
root.classList.toggle("is-open", next || Boolean(active));
if (next) {
lockScroll();
reveal();
} else {
releaseScroll();
if (restoreFocus) menu.focus();
}
sizeDrawer();
}
menu?.addEventListener("click", () => setSheet(!sheetOpen), { signal });
for (const b of root.querySelectorAll<HTMLButtonElement>("button.egh-acc-trigger")) {
b.addEventListener(
"click",
() => {
const region = root.querySelector<HTMLElement>(`#${CSS.escape(b.getAttribute("aria-controls") ?? "")}`);
const next = b.getAttribute("aria-expanded") !== "true";
/* One section open at a time, as on the bar. */
for (const other of root.querySelectorAll<HTMLButtonElement>("button.egh-acc-trigger[aria-expanded='true']")) {
if (other === b) continue;
other.setAttribute("aria-expanded", "false");
root.querySelector(`#${CSS.escape(other.getAttribute("aria-controls") ?? "")}`)?.classList.remove("is-open");
}
b.setAttribute("aria-expanded", String(next));
region?.classList.toggle("is-open", next);
},
{ signal },
);
}
/* Following a link in the sheet closes it (same-page anchors and client-routed pages alike). */
sheet?.addEventListener(
"click",
(e) => {
if ((e.target as HTMLElement).closest("a[href]")) setSheet(false, false);
},
{ signal },
);
/* ---------- Global: Escape, outside click, focus leaving, breakpoint ---------- */
document.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") return;
if (active) {
const t = active;
close();
t.focus();
} else if (sheetOpen) setSheet(false);
},
{ signal },
);
document.addEventListener(
"pointerdown",
(e) => {
if (root.contains(e.target as Node)) return;
if (active) close();
if (sheetOpen) setSheet(false, false);
},
{ signal },
);
root.addEventListener(
"focusout",
(e) => {
/* Only focus moving somewhere else closes it. A click on a non-focusable part of the panel
(padding, a heading) blurs to nothing and must not; clicks outside close via pointerdown. */
const to = e.relatedTarget as Node | null;
if (!to || root.contains(to)) return;
if (active) close();
if (sheetOpen) setSheet(false, false);
},
{ signal },
);
/*
* The frontmatter plans the bar from estimated label widths. Check the real one: if the sections
* would touch the brand or the actions, fold the number back to its button, then the sections into
* the sheet. Measured with the planned layout restored first, so a wider window can undo it.
*/
const nav = root.querySelector<HTMLElement>(".egh-nav");
const brand = root.querySelector<HTMLElement>(".egh-brand");
const actions = root.querySelector<HTMLElement>(".egh-actions");
const touching = () => {
if (!nav || !brand || !actions || nav.offsetParent === null) return false;
const n = nav.getBoundingClientRect();
return brand.getBoundingClientRect().right + 8 > n.left || n.right + 8 > actions.getBoundingClientRect().left;
};
const fit = () => {
root.classList.remove("is-crowded", "is-tight-call");
if (!touching()) return;
root.classList.add("is-tight-call");
if (touching()) root.classList.add("is-crowded");
};
fit();
document.fonts?.ready.then(() => {
if (!signal.aborted) fit();
});
const onResize = () => {
fit();
const inline = Boolean(nav && nav.offsetParent !== null);
if (inline && sheetOpen) setSheet(false, false);
if (!inline && active) close();
measure();
};
window.addEventListener("resize", onResize, { passive: true, signal });
/* ---------- Tuck away on scroll down, back on scroll up ---------- */
let lastY = window.scrollY;
let ticking = false;
function reveal() {
root.classList.remove("is-tucked");
}
const onScroll = () => {
ticking = false;
const y = window.scrollY;
const dy = y - lastY;
if (Math.abs(dy) < 6) return;
lastY = y;
/* Never tucked while in use: a panel or the sheet open, or a keyboard user focused inside it. */
if (root.dataset.hide === undefined || reduced.matches || active || sheetOpen || root.querySelector(":focus-visible")) return reveal();
root.classList.toggle("is-tucked", dy > 0 && y > root.offsetHeight + 40);
};
window.addEventListener(
"scroll",
() => {
if (!ticking) {
ticking = true;
requestAnimationFrame(onScroll);
}
},
{ passive: true, signal },
);
root.addEventListener(
"focusin",
() => {
if (root.querySelector(":focus-visible")) reveal();
},
{ signal },
);
document.addEventListener(
"astro:before-swap",
() => {
if (sheetOpen) setSheet(false, false);
controller.abort();
resize.disconnect();
sheetResize.disconnect();
window.clearTimeout(closeTimer);
window.clearTimeout(intentTimer);
delete root.dataset.eghReady;
},
{ once: true, signal },
);
}
function boot() {
for (const root of document.querySelectorAll<HTMLElement>(".egh")) init(root);
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot, { once: true });
else boot();
document.addEventListener("astro:page-load", boot);
</script>
===== END src/components/ExpandingGlassHeader.astro =====
===== FILE (supporting): src/components/ExpandingGlassHeader.md =====
# Expanding glass header
A dark glass capsule floating over the hero that grows into its own mega panel: the open section fills with the accent, its chevron rolls, and the panel height glides between sections. Panels hold columns of graded-icon rows, an optional lead row and up to three photo cards. Tucks away on scroll down, returns on scroll up; below the computed width it becomes a sheet of accordions inside the same capsule.
## Integration
- Put it first in <body> (after a skip link). It is position: sticky and reserves 84px (70px on phones); pass overlap to pull a dark hero up underneath it.
- Designed for a dark page start: the capsule is translucent glass. Over a light hero, set --egh-panel / --egh-ink or pick another header.
- Panels are the planner's tree in items[].groups / overview / features; a flat facts.links.primary list renders as plain links.
- Scripted: panels, the small-screen sheet and the tuck need JavaScript. Without it the plain links, the brand and the button still work, but sections with panels do not open.
- Written for <ClientRouter />: boots on astro:page-load, cleans up on astro:before-swap and counts its body scroll lock. Event-tested only; not yet run through a real router transition.
- Loads Geist and Geist Mono from Google Fonts by default. Pass fontStylesheet="" on a site that loads its own faces (via --egh-family / --egh-mono).
## Provenance
Staged as candidate `expanding-glass-header` (Step 28) and admitted through the candidate pipeline. Source type: visual-reference. References (direction only): https://www.cogent.com/. Quality history: quality/candidates/records/expanding-glass-header.json.
===== END src/components/ExpandingGlassHeader.md =====
--- VERIFIED IN THE COMPONENT LIBRARY ---
Enrolled for verification but never run.
No verification run has been recorded.
No checks have passed yet.
This record pre-dates source fingerprinting, so it is not tied to a specific revision.
--- NOT PROVEN ---
- ClientRouter: event-tested only. Handlers respond to dispatched lifecycle events; this has NOT been certified against a real <ClientRouter /> transition.
--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.
--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.
--- 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.