Three ways to get Sticky mega menu header into one of your own projects, all pre-filled with this component's name and library page.
Start here
Component reference
Paste it into a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving the branding and content open.
Read it first
“Sticky mega menu header” from my Astro Component Library:
https://astro.baysixmedia.com/components/sticky-mega-menu-header/
Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.
The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.
ChatGPT brief
Ask ChatGPT to plan a more detailed customization. Use it when you are still deciding which project the component belongs in, where on the page it sits, and what needs changing. It answers with a Claude Code prompt to run.
Read it first
Help me add this component to one of my Astro websites:
Component: Sticky mega menu header
Library page: https://astro.baysixmedia.com/components/sticky-mega-menu-header
I want it added to:
[ENTER PROJECT, PAGE, OR ROUTE]
Place it:
[ENTER LOCATION OR SELECTOR]
Customize it for:
[ENTER BUSINESS, CONTENT, COLORS, IMAGES, OR OTHER CHANGES]
Create a complete Claude Code implementation prompt. Tell Claude to read the component's source, README, documented props, dependencies, and required assets from the library page; copy everything locally into the target project; preserve its responsive behavior, accessibility, interactions, reduced-motion behavior, and multiple-instance safety; test it at desktop, tablet, and mobile widths; run the target project's available check and build commands; report all changed files and differences from the library version; and avoid committing or deploying.
Placement prompt
Send the implementation request straight to Claude Code. Use it when you already know which project, which page and where on that page it goes. Paste it into Claude Code inside the destination project and it does the work.
Read it first
Add Sticky mega menu header from my Astro Component Library to this Astro project.
Library page:
https://astro.baysixmedia.com/components/sticky-mega-menu-header
First read the component source, README, documented props, dependencies, and required assets from the library page. Copy the component and every required companion asset locally into this project. Do not import files from the deployed library at runtime.
Place it in:
[ENTER PAGE, FILE, OR ROUTE]
Position:
[ENTER WHERE IT SHOULD APPEAR]
Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.
Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.
src/components/library/StickyMegaMenuHeader.astro
one fileNo imports, no companion files, no packages. Copy StickyMegaMenuHeader.astro into any Astro project's components folder and render it.
---
/**
* Sticky mega menu header
*
* A marketing site header that sticks to the top of the viewport, turns from
* transparent to frosted glass the moment the page scrolls, opens wide mega
* panels from its nav items, and collapses to a full-screen sheet below the
* desktop breakpoint.
*
* Self-contained: no imports, no packages, no global stylesheet, no images.
* Every colour and measurement is a `--mh-*` custom property on the root.
*/
export interface MegaLink {
label: string;
href: string;
/** Second line under the label inside a mega panel. */
description?: string;
}
export interface MegaGroup {
/** Small heading above a column of links. `""` renders no heading. */
heading?: string;
links: MegaLink[];
}
export interface NavItem {
label: string;
/** A plain destination. Give this OR `groups`, not both. */
href?: string;
/** Turns the item into a mega-panel trigger. */
groups?: MegaGroup[];
}
interface Props {
/** Wordmark text beside the brand mark. `""` renders the mark alone. */
brandName?: string;
/** Where the brand links to. */
brandHref?: string;
/** Accessible name for the primary `<nav>`. */
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"`. */
currentPath?: string;
/** Header height. The reference holds this constant on scroll. */
height?: string;
/** Caps the header's inner row. */
maxWidth?: string;
/** Page ground the header sits on. Also the colour it fades in when scrolled. */
background?: string;
/** Mega panels and the mobile sheet. */
surface?: string;
/** Primary text. */
ink?: string;
/** Descriptions and group headings. */
muted?: string;
/** Hairlines on panels and the sheet. */
border?: string;
/** Primary button fill. */
ctaBg?: string;
/** Primary button text. */
ctaInk?: string;
/** Focus ring and the current-page marker. */
accent?: string;
/** Backdrop blur once scrolled. `0px` turns the glass off. */
blur?: string;
/** Stacking order. It floats over the page, so it has to win. */
zIndex?: number;
/** Added to the root element. */
class?: string;
/** Root element id. Also seeds the panel and sheet ids. */
id?: string;
}
const {
brandName = "Kettlebridge",
brandHref = "/",
navLabel = "Primary",
menuLabel = "Toggle navigation menu",
items = [
{
label: "Platform",
groups: [
{
heading: "Who it is for",
links: [
{ label: "Product teams", href: "/for/product", description: "Ship the thing you sketched." },
{ label: "Designers", href: "/for/design", description: "Your layouts, built properly." },
{ label: "Founders", href: "/for/founders", description: "A demo before the deck." },
{ label: "Operations", href: "/for/ops", description: "Tools shaped to your process." },
],
},
{
heading: "What it does",
links: [
{ label: "Drafting", href: "/drafting", description: "Start from a rough idea." },
{ label: "Review", href: "/review", description: "Comments where the work is." },
{ label: "Handover", href: "/handover", description: "Everything the next person needs." },
{ label: "Archive", href: "/archive", description: "Findable a year later." },
],
},
],
},
{
label: "Resources",
groups: [
{
heading: "Learn",
links: [
{ label: "Guides", href: "/guides", description: "Short, practical walkthroughs." },
{ label: "Changelog", href: "/changelog", description: "What moved this month." },
{ label: "Field notes", href: "/notes", description: "Writing from the team." },
],
},
],
},
{ label: "Community", href: "/community" },
{ label: "Pricing", href: "/pricing" },
{ label: "Security", href: "/security" },
],
loginLabel = "Log in",
loginHref = "/login",
ctaLabel = "Get started",
ctaHref = "/signup",
currentPath = "/pricing",
height = "64px",
maxWidth = "1280px",
background = "#fcfbf8",
surface = "#ffffff",
ink = "#1a1a1a",
muted = "#6b6b6b",
border = "#e4e2dc",
ctaBg = "rgb(0 0 0 / 88%)",
ctaInk = "#fbfbfb",
accent = "#3b5bdb",
blur = "24px",
zIndex = 50,
class: className,
id,
} = Astro.props;
// One id prefix per instance, so several headers can share a page without
// their triggers and panels pointing at each other's elements.
const uid = id ?? `mh-${Math.random().toString(36).slice(2, 9)}`;
const sheetId = `${uid}-sheet`;
const isCurrent = (href?: string) => Boolean(href && currentPath && href === currentPath);
const style = [
`--mh-h:${height}`,
`--mh-max:${maxWidth}`,
`--mh-bg:${background}`,
`--mh-surface:${surface}`,
`--mh-ink:${ink}`,
`--mh-muted:${muted}`,
`--mh-border:${border}`,
`--mh-cta-bg:${ctaBg}`,
`--mh-cta-ink:${ctaInk}`,
`--mh-accent:${accent}`,
`--mh-blur:${blur}`,
`--mh-z:${zIndex}`,
].join(";");
---
<div class:list={["header", className]} id={id} style={style} data-mh-root>
{/*
Sticky and the scroll state both live here. In the reference they sit on
an ancestor two levels above the matched element; folding them in is what
makes this one portable file rather than a fragment that needs a wrapper.
*/}
<div class="bar" data-mh-bar>
<div class="inner">
<a class="brand" href={brandHref}>
<svg class="mark" viewBox="0 0 28 28" aria-hidden="true" focusable="false">
<rect x="3" y="3" width="22" height="22" rx="7"></rect>
<path d="M10 18.5V9.5l8 9v-9"></path>
</svg>
{brandName && <span class="brand-name">{brandName}</span>}
{!brandName && <span class="sr-only">Home</span>}
</a>
<nav class="desk" aria-label={navLabel}>
<ul class="row">
{
items.map((item, i) => {
const panelId = `${uid}-panel-${i}`;
return item.groups && item.groups.length > 0 ? (
<li class="item" data-mh-item>
<button
type="button"
class="trigger"
data-mh-trigger
aria-expanded="false"
aria-controls={panelId}
aria-haspopup="true"
>
{item.label}
<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6.5 8 10.5 12 6.5"></path>
</svg>
</button>
<div class="panel" id={panelId} data-mh-panel>
<div class="card">
{item.groups.map((group) => (
<div class="group">
{group.heading && <p class="group-head">{group.heading}</p>}
<ul class="group-list">
{group.links.map((link) => (
<li>
<a
class="cell"
href={link.href}
aria-current={isCurrent(link.href) ? "page" : null}
>
<span class="cell-title">{link.label}</span>
{link.description && (
<span class="cell-desc">{link.description}</span>
)}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
</li>
) : (
<li class="item">
<a
class="link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : null}
>
{item.label}
</a>
</li>
);
})
}
</ul>
</nav>
<div class="actions">
{loginLabel && loginHref && <a class="ghost" href={loginHref}>{loginLabel}</a>}
{ctaLabel && ctaHref && <a class="cta" href={ctaHref}>{ctaLabel}</a>}
</div>
<button
type="button"
class="burger"
data-mh-burger
aria-expanded="false"
aria-controls={sheetId}
aria-label={menuLabel}
>
<svg class="bars" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<line class="bar-a" x1="4" y1="9" x2="20" y2="9"></line>
<line class="bar-b" x1="4" y1="15" x2="20" y2="15"></line>
</svg>
</button>
</div>
</div>
{/*
The small-screen counterpart. In the reference this is a sibling element
in the same header slot, shown by the inverse of the same breakpoint, and
it carries the same primary navigation — which is why it belongs in the
same component rather than being treated as an unrelated control.
*/}
<div class="sheet" id={sheetId} data-mh-sheet>
<nav class="sheet-nav" aria-label={navLabel}>
{
items.map((item) =>
item.groups && item.groups.length > 0 ? (
<details class="sheet-group" name={`${uid}-sheet-acc`}>
<summary class="sheet-summary">
{item.label}
<svg class="chev" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<path d="M4 6.5 8 10.5 12 6.5"></path>
</svg>
</summary>
<ul class="sheet-sub">
{item.groups.flatMap((g) => g.links).map((link) => (
<li>
<a
class="sheet-link is-sub"
href={link.href}
aria-current={isCurrent(link.href) ? "page" : null}
>
{link.label}
</a>
</li>
))}
</ul>
</details>
) : (
<a
class="sheet-link"
href={item.href}
aria-current={isCurrent(item.href) ? "page" : null}
>
{item.label}
</a>
),
)
}
<div class="sheet-actions">
{loginLabel && loginHref && <a class="ghost wide" href={loginHref}>{loginLabel}</a>}
{ctaLabel && ctaHref && <a class="cta wide" href={ctaHref}>{ctaLabel}</a>}
</div>
</nav>
</div>
</div>
<style>
/*
* Sticky lives on the root, not on the bar inside it. A sticky element can
* only travel within its own parent's box — and this root is exactly as
* tall as the bar, so sticking the bar to the root would give it nowhere to
* go and it would scroll away with the page. On the root, the containing
* block is whatever the host puts it in, which is the whole page.
*/
.header {
--mh-pad: 16px;
position: sticky;
top: 0;
z-index: var(--mh-z);
box-sizing: border-box;
color: var(--mh-ink);
font-family:
system-ui,
-apple-system,
"Segoe UI",
Roboto,
sans-serif;
font-size: 16px;
line-height: 1.5;
}
/* Set rather than inherited: a destination site's global rules must not be
able to change the header's height or push a row onto a second line. */
.header *,
.header *::before,
.header *::after {
box-sizing: border-box;
}
.header ul {
margin: 0;
padding: 0;
list-style: none;
}
.header p {
margin: 0;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/* --- the sticky bar ---------------------------------------------------- */
.bar {
height: var(--mh-h);
background: transparent;
/* Only these two animate in the reference; the blur arrives instantly. */
transition:
background-color 300ms cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 300ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Set from the first pixel of scroll, exactly as the reference flips it. */
[data-mh-scrolled] .bar {
background: color-mix(in srgb, var(--mh-bg) 75%, transparent);
-webkit-backdrop-filter: blur(var(--mh-blur));
backdrop-filter: blur(var(--mh-blur));
box-shadow: 0 1px 2px rgb(0 0 0 / 4%), 0 8px 24px -12px rgb(0 0 0 / 12%);
}
.inner {
display: flex;
align-items: center;
gap: 16px;
height: 100%;
max-width: var(--mh-max);
margin-inline: auto;
padding-inline: var(--mh-pad);
}
/* --- brand -------------------------------------------------------------- */
.brand {
display: inline-flex;
flex: none;
align-items: center;
gap: 8px;
min-height: 44px;
color: var(--mh-ink);
text-decoration: none;
}
.mark {
width: 28px;
height: 28px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.brand-name {
font-size: 1rem;
font-weight: 600;
letter-spacing: -0.01em;
white-space: nowrap;
}
/* --- desktop nav --------------------------------------------------------- */
.desk {
display: none;
}
.row {
display: flex;
align-items: center;
gap: 2px;
}
.item {
position: relative;
display: flex;
align-items: center;
}
.link,
.trigger {
display: inline-flex;
align-items: center;
gap: 2px;
min-height: 44px;
margin: 0;
padding: 4px 8px;
border: 0;
border-radius: 8px;
background: none;
color: var(--mh-ink);
font: inherit;
font-size: 0.9375rem;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.link:hover,
.trigger:hover,
[data-mh-open] > .trigger {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.link[aria-current="page"] {
color: var(--mh-accent);
font-weight: 500;
}
.chev {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-mh-open] > .trigger .chev {
transform: rotate(180deg);
}
/* --- mega panel ----------------------------------------------------------- */
/*
* Hidden by default and revealed on hover or focus-within, so the panels
* work with no JavaScript at all. Once the script runs it takes over with
* `data-mh-open`, which is what makes Escape and outside-click possible.
*/
/*
* `top: 100%` with the visual offset supplied by padding, not by a gap.
* A real gap between the trigger and the panel is a dead zone: the pointer
* crosses it, leaves the item, and the panel closes underneath. Padding
* keeps the panel's hit box flush against the trigger, so travelling down
* into it never leaves the item at all.
*/
.panel {
position: absolute;
top: 100%;
left: -8px;
z-index: 1;
padding-top: 6px;
visibility: hidden;
opacity: 0;
transform: translateY(-4px);
transition:
opacity 150ms cubic-bezier(0.4, 0, 0.2, 1),
transform 150ms cubic-bezier(0.4, 0, 0.2, 1),
visibility 150ms;
}
.item:hover > .panel,
.item:focus-within > .panel,
.item[data-mh-open] > .panel {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
/* With the script running, hover and focus alone no longer open a panel —
the attribute is the single source of truth, so Escape actually closes. */
[data-mh-js] .item:hover > .panel,
[data-mh-js] .item:focus-within > .panel {
visibility: hidden;
opacity: 0;
transform: translateY(-4px);
}
[data-mh-js] .item[data-mh-open] > .panel {
visibility: visible;
opacity: 1;
transform: translateY(0);
}
.card {
display: flex;
gap: 28px;
padding: 16px;
border: 1px solid var(--mh-border);
border-radius: 14px;
background: var(--mh-surface);
box-shadow:
0 2px 4px rgb(0 0 0 / 4%),
0 16px 40px -12px rgb(0 0 0 / 18%);
}
.group-head {
margin-bottom: 6px;
padding-inline: 12px;
color: var(--mh-muted);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.group-list {
display: grid;
gap: 2px;
}
.cell {
display: block;
width: 240px;
padding: 8px 12px;
border-radius: 10px;
color: var(--mh-ink);
text-decoration: none;
transition: background-color 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.cell:hover {
background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
}
.cell-title {
display: block;
font-size: 1rem;
font-weight: 500;
line-height: 1.35;
}
.cell[aria-current="page"] .cell-title {
color: var(--mh-accent);
}
.cell-desc {
display: block;
color: var(--mh-muted);
font-size: 0.875rem;
line-height: 1.3;
}
/* --- right-hand actions ---------------------------------------------------- */
.actions {
display: none;
flex: none;
gap: 8px;
margin-inline-start: auto;
}
.ghost,
.cta {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 6px 12px;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
white-space: nowrap;
transition:
background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
filter 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.ghost {
color: var(--mh-ink);
}
.ghost:hover {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.cta {
background: var(--mh-cta-bg);
color: var(--mh-cta-ink);
}
.cta:hover {
filter: brightness(1.18);
}
.wide {
width: 100%;
}
/* --- burger ----------------------------------------------------------------- */
.burger {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
margin-inline-start: auto;
padding: 0;
border: 0;
border-radius: 10px;
background: none;
color: var(--mh-ink);
cursor: pointer;
}
.burger:hover {
background: color-mix(in srgb, var(--mh-ink) 6%, transparent);
}
.bars {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.bar-a,
.bar-b {
transform-origin: center;
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
[data-mh-sheet-open] .bar-a {
transform: translateY(3px) rotate(45deg);
}
[data-mh-sheet-open] .bar-b {
transform: translateY(-3px) rotate(-45deg);
}
/* --- mobile sheet -------------------------------------------------------------- */
.sheet {
position: fixed;
inset: var(--mh-h) 0 0;
z-index: calc(var(--mh-z) - 1);
overflow-y: auto;
padding: 16px var(--mh-pad) 32px;
background: var(--mh-surface);
}
.sheet-nav {
display: flex;
flex-direction: column;
gap: 2px;
max-width: var(--mh-max);
margin-inline: auto;
}
.sheet-link,
.sheet-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 48px;
padding: 10px 12px;
border-radius: 10px;
color: var(--mh-ink);
font-size: 1.0625rem;
font-weight: 500;
text-decoration: none;
cursor: pointer;
list-style: none;
}
.sheet-summary::-webkit-details-marker {
display: none;
}
.sheet-link:hover,
.sheet-summary:hover {
background: color-mix(in srgb, var(--mh-ink) 5%, transparent);
}
.sheet-link[aria-current="page"] {
color: var(--mh-accent);
}
.sheet-group[open] > .sheet-summary .chev {
transform: rotate(180deg);
}
.sheet-sub {
display: grid;
gap: 2px;
margin: 2px 0 8px;
padding-inline-start: 12px;
border-inline-start: 2px solid var(--mh-border);
}
.sheet-link.is-sub {
min-height: 44px;
font-size: 1rem;
font-weight: 400;
color: var(--mh-muted);
}
.sheet-link.is-sub:hover {
color: var(--mh-ink);
}
.sheet-actions {
display: grid;
gap: 8px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--mh-border);
}
/*
* Closed by default only once the script is running. Without JavaScript the
* sheet stays in the flow below the bar, every destination reachable, and
* the `<details>` groups still open and close on their own.
*/
[data-mh-js] .sheet {
display: none;
}
[data-mh-js][data-mh-sheet-open] .sheet {
display: block;
}
[data-mh-js] .sheet {
position: fixed;
}
.header:not([data-mh-js]) .sheet {
position: static;
inset: auto;
border-bottom: 1px solid var(--mh-border);
}
/* --- focus ---------------------------------------------------------------------- */
/* One ring for every control. The reference leaves the browser default,
which computes to about 0.67px. */
.brand:focus-visible,
.link:focus-visible,
.trigger:focus-visible,
.cell:focus-visible,
.ghost:focus-visible,
.cta:focus-visible,
.burger:focus-visible,
.sheet-link:focus-visible,
.sheet-summary:focus-visible {
outline: 3px solid var(--mh-accent);
outline-offset: 2px;
}
/* --- desktop breakpoint: measured at exactly 1024px ------------------------------- */
@media (min-width: 768px) {
.header {
--mh-pad: 32px;
}
}
@media (min-width: 1024px) {
.header {
--mh-pad: 64px;
}
.desk {
display: block;
margin-inline-start: 24px;
}
.actions {
display: flex;
}
.burger,
.sheet {
display: none;
}
/* Keeps the no-JS rule from resurrecting the sheet on desktop. */
.header:not([data-mh-js]) .sheet {
display: none;
}
}
@media (min-width: 1280px) {
.header {
--mh-pad: 80px;
}
.desk {
margin-inline-start: 40px;
}
}
/* --- reduced motion ----------------------------------------------------------------- */
/*
* Everything still happens — the bar still frosts, panels still open, the
* sheet still appears — it just happens instantly. The reference honours
* none of this; its 300ms header transition runs regardless.
*/
@media (prefers-reduced-motion: reduce) {
.bar,
.panel,
.chev,
.link,
.trigger,
.cell,
.ghost,
.cta,
.bar-a,
.bar-b {
transition-duration: 0.01ms;
}
}
</style>
<script>
/**
* One controller per instance. Every element is looked up inside its own
* root, so several headers can share a page without touching each other.
*/
function initStickyMegaMenuHeader(root: HTMLElement) {
const bar = root.querySelector<HTMLElement>("[data-mh-bar]");
const items = Array.from(root.querySelectorAll<HTMLElement>("[data-mh-item]"));
const burger = root.querySelector<HTMLButtonElement>("[data-mh-burger]");
const sheet = root.querySelector<HTMLElement>("[data-mh-sheet]");
if (!bar) return;
// Enables the JS-driven states. Without this the CSS keeps the panels on
// hover/focus-within and the sheet in the flow, so nothing is unreachable.
root.setAttribute("data-mh-js", "");
/* ---- scroll state: flips at the first pixel, as measured ---- */
let frame = 0;
function measure() {
const past = window.scrollY > 0;
if (past === root.hasAttribute("data-mh-scrolled")) return;
if (past) root.setAttribute("data-mh-scrolled", "");
else root.removeAttribute("data-mh-scrolled");
}
function schedule() {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
measure();
});
}
window.addEventListener("scroll", schedule, { passive: true });
measure();
/* ---- mega panels ---- */
let openItem: HTMLElement | null = null;
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function setOpen(item: HTMLElement | null) {
if (openItem === item) return;
for (const other of items) {
const on = other === item;
other.toggleAttribute("data-mh-open", on);
const trigger = other.querySelector<HTMLButtonElement>("[data-mh-trigger]");
trigger?.setAttribute("aria-expanded", String(on));
}
openItem = item;
}
// A short grace period, so the pointer can cross the gap between the
// trigger and the panel below it without the panel vanishing underneath.
function scheduleClose() {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => setOpen(null), 260);
}
function cancelClose() {
clearTimeout(closeTimer);
}
for (const item of items) {
const trigger = item.querySelector<HTMLButtonElement>("[data-mh-trigger]");
if (!trigger) continue;
// Hover opens, matching the reference. Moving to another trigger
// swaps which panel is open rather than stacking two.
item.addEventListener("pointerenter", () => {
cancelClose();
setOpen(item);
});
item.addEventListener("pointerleave", scheduleClose);
// Enter and Space both reach this through a <button>'s native click.
trigger.addEventListener("click", () => {
cancelClose();
setOpen(openItem === item ? null : item);
});
// Tabbing out of the last link in a panel closes it, so focus never
// sits behind an invisible layer.
item.addEventListener("focusout", (event) => {
const next = event.relatedTarget as Node | null;
if (next && item.contains(next)) return;
if (openItem === item) setOpen(null);
});
}
/* ---- mobile sheet ---- */
let sheetOpen = false;
let scrollLock = "";
function setSheet(next: boolean) {
if (!burger || !sheet) return;
sheetOpen = next;
root.toggleAttribute("data-mh-sheet-open", next);
burger.setAttribute("aria-expanded", String(next));
if (next) {
scrollLock = document.body.style.overflow;
document.body.style.overflow = "hidden";
} else {
// Restore exactly what was there, rather than assuming "visible".
document.body.style.overflow = scrollLock;
}
}
burger?.addEventListener("click", () => setSheet(!sheetOpen));
sheet?.addEventListener("click", (event) => {
if ((event.target as HTMLElement).closest("a")) setSheet(false);
});
/* ---- shared dismissal ---- */
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
if (openItem) {
const trigger = openItem.querySelector<HTMLButtonElement>("[data-mh-trigger]");
setOpen(null);
trigger?.focus();
} else if (sheetOpen) {
setSheet(false);
burger?.focus();
}
});
document.addEventListener("pointerdown", (event) => {
if (root.contains(event.target as Node)) return;
setOpen(null);
if (sheetOpen) setSheet(false);
});
// Crossing the breakpoint with the sheet open would otherwise leave the
// body locked and a hidden sheet still flagged open.
const wide = window.matchMedia("(min-width: 1024px)");
const onChange = () => {
if (wide.matches && sheetOpen) setSheet(false);
if (!wide.matches && openItem) setOpen(null);
};
if (typeof wide.addEventListener === "function") wide.addEventListener("change", onChange);
}
document
.querySelectorAll<HTMLElement>("[data-mh-root]")
.forEach(initStickyMegaMenuHeader);
</script>