site(header): Cursor-style mega-menu for Product

Hover or focus the Product nav item to reveal a wide (720px) mega-menu:
- Top: featured 'Product overview' card with sky-gradient icon tile, plus
  a Resources column (Deliverability, API, Integrations, Changelog).
- Two-column product grid grouped 'Send & deliver' (Warmup, Sending,
  Workers, Security) and 'Engage & analyse' (Inbox, Campaigns, CRM,
  Analytics) with icon + title + 1-line description per item.
- Footer band with open-source link and pricing link.
- Caret rotates 180° on open, panel fades + slides down 200ms.
- Hover-bridge with 140ms close delay so cursor can travel from
  trigger to panel without flicker. Escape closes.
This commit is contained in:
Matthew Meszaros
2026-05-26 13:48:54 +00:00
parent b80495cc9b
commit 66f047e1aa
+173 -19
View File
@@ -1,5 +1,6 @@
---
import Wordmark from './Wordmark.astro';
import Icon from './Icon.astro';
interface Props {
/** Render as transparent over the sky hero, with light text. Becomes solid once scrolled past the hero. */
@@ -8,13 +9,34 @@ interface Props {
const { onSky = false } = Astro.props;
const path = Astro.url.pathname;
const nav: { label: string; href: string; match?: string }[] = [
{ label: 'Product', href: '/product/', match: '/product' },
{ label: 'Warmup', href: '/warmup/', match: '/warmup' },
{ label: 'Deliverability', href: '/deliverability/', match: '/deliverability' },
{ label: 'Security', href: '/security/', match: '/security' },
{ label: 'Pricing', href: '/pricing/', match: '/pricing' },
{ label: 'Learn', href: '/learn/', match: '/learn' },
// Product mega-menu items grouped into two columns
const productGroups = [
{
label: 'Send & deliver',
items: [
{ icon: 'flame', href: '/warmup/', title: 'Warmup', desc: 'Reputation-safe pools and gradual ramps.' },
{ icon: 'send', href: '/sending/', title: 'Sending engine', desc: 'Distributed across mailboxes, workers, IPs.' },
{ icon: 'cpu', href: '/workers/', title: 'Workers', desc: 'One worker per machine, never NAT-shared.' },
{ icon: 'shield', href: '/security/', title: 'Security', desc: 'Envelope encryption with AWS KMS.' },
],
},
{
label: 'Engage & analyse',
items: [
{ icon: 'inbox', href: '/inbox/', title: 'Unified inbox', desc: 'Every reply across every mailbox, classified.' },
{ icon: 'workflow', href: '/campaigns/', title: 'Campaigns', desc: 'Sequences with branching and A/B variants.' },
{ icon: 'contact', href: '/crm/', title: 'CRM', desc: 'Pipelines, deals and tasks next to sending.' },
{ icon: 'chart', href: '/analytics/', title: 'Analytics', desc: 'Placement, complaints, bounces, by mailbox.' },
],
},
];
const nav: { label: string; href: string; match?: string; mega?: boolean }[] = [
{ label: 'Product', href: '/product/', match: '/product', mega: true },
{ label: 'Deliverability',href: '/deliverability/',match: '/deliverability' },
{ label: 'Pricing', href: '/pricing/', match: '/pricing' },
{ label: 'Learn', href: '/learn/', match: '/learn' },
];
function active(match?: string) {
@@ -33,19 +55,110 @@ function active(match?: string) {
<div class="container-page flex h-14 items-center gap-6">
<Wordmark size="md" />
<nav class="hidden md:flex items-center gap-1 ml-2">
<nav class="hidden md:flex items-center gap-1 ml-2 relative">
{nav.map((n) => (
<a
href={n.href}
class={
'nav-link h-8 px-3 inline-flex items-center rounded-md text-[13px] transition-colors ' +
(active(n.match)
? (onSky ? 'is-active' : 'text-foreground bg-surface-2')
: (onSky ? '' : 'text-muted-foreground hover:text-foreground hover:bg-surface-2/60'))
}
>
{n.label}
</a>
n.mega ? (
<div class="mega-wrapper relative" data-mega>
<a
href={n.href}
class={
'nav-link h-8 px-3 inline-flex items-center gap-1 rounded-md text-[13px] transition-colors ' +
(active(n.match)
? (onSky ? 'is-active' : 'text-foreground bg-surface-2')
: (onSky ? '' : 'text-muted-foreground hover:text-foreground hover:bg-surface-2/60'))
}
>
{n.label}
<svg width="9" height="9" viewBox="0 0 12 12" fill="none" class="mega-caret transition-transform duration-200" aria-hidden="true">
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</a>
<!-- Mega-menu panel -->
<div class="mega-panel absolute left-1/2 -translate-x-1/2 top-full pt-2 pointer-events-none opacity-0 -translate-y-1 transition-all duration-200 ease-out" style="width: 720px; max-width: 90vw;">
<div class="rounded-[16px] bg-white ring-1 ring-[color:var(--border)] overflow-hidden" style="box-shadow: 0 24px 60px -20px rgba(15,23,42,0.25), 0 8px 24px -8px rgba(15,23,42,0.08);">
<!-- Top: overview + columns -->
<div class="grid grid-cols-[1.4fr_1fr] gap-px bg-[color:var(--border)]">
<!-- Featured: product overview -->
<a href="/product/" class="group bg-white p-5 hover:bg-[color:var(--sky-1)]/40 transition-colors">
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-[10px] bg-gradient-to-br from-[#0284c7] to-[#075985] inline-flex items-center justify-center shrink-0">
<Icon name="layers" size={17} class="text-white" />
</div>
<div>
<div class="text-[13.5px] font-semibold text-heading group-hover:text-[#0369a1] transition-colors">Product overview</div>
<div class="mt-0.5 text-[11.5px] text-foreground/65 leading-snug">Nine systems, one platform.</div>
</div>
</div>
<div class="mt-3 ml-12 inline-flex items-center gap-1 text-[11.5px] font-medium text-[#0369a1] opacity-0 group-hover:opacity-100 transition-opacity">
See all <Icon name="arrowRight" size={11} />
</div>
</a>
<!-- Quick links -->
<div class="bg-[color:var(--surface-1)] p-5">
<div class="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-3">Resources</div>
<div class="space-y-2">
<a href="/deliverability/" class="block text-[12.5px] text-foreground/75 hover:text-[#0369a1] transition-colors">Deliverability →</a>
<a href="/developers/" class="block text-[12.5px] text-foreground/75 hover:text-[#0369a1] transition-colors">API & webhooks →</a>
<a href="/integrations/" class="block text-[12.5px] text-foreground/75 hover:text-[#0369a1] transition-colors">Integrations →</a>
<a href="/changelog/" class="block text-[12.5px] text-foreground/75 hover:text-[#0369a1] transition-colors">Changelog →</a>
</div>
</div>
</div>
<!-- Product groups grid -->
<div class="grid grid-cols-2 gap-px bg-[color:var(--border)] border-t border-[color:var(--border)]">
{productGroups.map((g) => (
<div class="bg-white p-5">
<div class="text-[10px] uppercase tracking-[0.18em] font-mono text-muted-foreground mb-3 flex items-center gap-2">
<span>{g.label}</span>
<span class="flex-1 h-px bg-[color:var(--border)]"></span>
</div>
<div class="space-y-1">
{g.items.map((it) => (
<a href={it.href} class="mega-item group flex items-start gap-3 p-2 -mx-2 rounded-[8px] hover:bg-[color:var(--sky-1)]/50 transition-colors">
<div class="w-8 h-8 rounded-[8px] bg-[color:var(--surface-1)] text-[#0284c7] ring-1 ring-[color:var(--border)] group-hover:bg-white group-hover:ring-[color:var(--sky-3)] inline-flex items-center justify-center shrink-0 transition-all">
<Icon name={it.icon} size={15} />
</div>
<div class="min-w-0 flex-1">
<div class="text-[13px] font-semibold text-heading group-hover:text-[#0369a1] transition-colors">{it.title}</div>
<div class="text-[11.5px] text-foreground/60 leading-snug truncate">{it.desc}</div>
</div>
<Icon name="arrowRight" size={11} class="text-foreground/30 group-hover:text-[#0369a1] group-hover:translate-x-0.5 transition-all mt-2 shrink-0" />
</a>
))}
</div>
</div>
))}
</div>
<!-- Footer -->
<div class="bg-[color:var(--surface-1)] px-5 py-3 border-t border-[color:var(--border)] flex items-center justify-between text-[11.5px]">
<a href="https://github.com/warmbly/warmbly" class="inline-flex items-center gap-1.5 font-mono text-foreground/70 hover:text-foreground transition-colors">
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.8 10.9.6.1.8-.2.8-.5v-2c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1.1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1.1 1.8 2.8 1.3 3.5 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.2-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.3 1.2 1-.3 2-.4 3-.4s2 .1 3 .4c2.3-1.5 3.3-1.2 3.3-1.2.7 1.6.2 2.9.1 3.1.8.8 1.2 1.9 1.2 3.2 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.5 4.6-1.5 7.8-5.8 7.8-10.9C23.5 5.7 18.3.5 12 .5z"/></svg>
Open source · Apache 2.0
</a>
<a href="/pricing/" class="inline-flex items-center gap-1 font-medium text-[#0369a1] hover:text-[#075985]">
See pricing <Icon name="arrowRight" size={11} />
</a>
</div>
</div>
</div>
</div>
) : (
<a
href={n.href}
class={
'nav-link h-8 px-3 inline-flex items-center rounded-md text-[13px] transition-colors ' +
(active(n.match)
? (onSky ? 'is-active' : 'text-foreground bg-surface-2')
: (onSky ? '' : 'text-muted-foreground hover:text-foreground hover:bg-surface-2/60'))
}
>
{n.label}
</a>
)
))}
</nav>
@@ -108,6 +221,15 @@ function active(match?: string) {
</div>
</header>
<style>
.mega-panel.open {
opacity: 1;
transform: translate(-50%, 0);
pointer-events: auto;
}
.mega-wrapper.open .mega-caret { transform: rotate(180deg); }
</style>
<script>
// Mobile nav toggle
const toggle = document.getElementById('nav-toggle');
@@ -117,6 +239,38 @@ function active(match?: string) {
toggle.setAttribute('aria-expanded', String(open));
});
// Hover-bridge mega-menu (delayed close so cursor can move into panel)
document.querySelectorAll<HTMLElement>('[data-mega]').forEach((wrapper) => {
const panel = wrapper.querySelector<HTMLElement>('.mega-panel');
if (!panel) return;
let closeTimer: number | null = null;
const open = () => {
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
wrapper.classList.add('open');
panel.classList.add('open');
};
const scheduleClose = () => {
if (closeTimer) clearTimeout(closeTimer);
closeTimer = window.setTimeout(() => {
wrapper.classList.remove('open');
panel.classList.remove('open');
}, 140);
};
wrapper.addEventListener('mouseenter', open);
wrapper.addEventListener('mouseleave', scheduleClose);
panel.addEventListener('mouseenter', open);
panel.addEventListener('mouseleave', scheduleClose);
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
wrapper.classList.remove('open');
panel.classList.remove('open');
}
});
});
// If the header is rendered "on sky", flip it to the solid look once
// the hero's bottom sentinel scrolls past the header. Pure IO, no
// scroll listener — cheap.