"use client";
import * as React from "react";
import { animate, motion, useMotionTemplate, useMotionValue } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface OverlayMenuItem {
label: string;
href: string;
/** Small annotation beside the link, e.g. a count or "New". */
meta?: string;
}
export interface OverlayMenuProps {
items: OverlayMenuItem[];
/** href of the current page; that link gets aria-current="page". */
currentHref?: string;
/** Open state (controlled). */
open?: boolean;
/** Initial open state (uncontrolled). */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
/** Called when a link is activated. Call event.preventDefault() for client-side routing. */
onNavigate?: (item: OverlayMenuItem, event: React.MouseEvent<HTMLAnchorElement>) => void;
/** Close the overlay after a link is activated. */
closeOnNavigate?: boolean;
/** Secondary column (contact, socials, hours…). Hidden below the md breakpoint. */
aside?: React.ReactNode;
/** How the background appears: a circle growing from the button, or a fade. */
reveal?: "circle" | "fade";
/** Overlay colours: inverse of the page theme, the accent, or the page surface. */
tone?: "inverse" | "accent" | "surface";
/** Show 01, 02… before each link. */
showIndex?: boolean;
/** Length of the background reveal, in ms. */
duration?: number;
/** Delay between links as they rise into view, in ms. */
stagger?: number;
/** Accent colour (accent tone background, markers, focus rings). */
accent?: string;
/** Accessible name of the dialog and its nav. */
title?: string;
/** Accessible name of the trigger when closed. */
openLabel?: string;
/** Accessible name of the close button. */
closeLabel?: string;
/** Classes added to the trigger button. */
className?: string;
}
const TONES: Record<NonNullable<OverlayMenuProps["tone"]>, string> = {
inverse: "bg-zinc-950 text-zinc-50 dark:bg-zinc-50 dark:text-zinc-950",
accent: "bg-[var(--om-accent)] text-zinc-950",
surface: "bg-white text-zinc-950 dark:bg-zinc-900 dark:text-zinc-50",
};
const EASE = [0.76, 0, 0.24, 1] as const;
const EASE_OUT = [0.16, 1, 0.3, 1] as const;
/** Two bars that morph between a hamburger and a cross. */
function MorphIcon({ open, reduceMotion }: { open: boolean; reduceMotion: boolean }) {
const transition = reduceMotion ? { duration: 0 } : { duration: 0.35, ease: EASE };
return (
<svg viewBox="0 0 24 24" className="h-5 w-5" aria-hidden="true" fill="none">
<motion.line
x1="4" x2="20" y1="9" y2="9"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"
style={{ originX: "50%", originY: "50%" }}
initial={false}
animate={open ? { rotate: 45, y: 3 } : { rotate: 0, y: 0 }}
transition={transition}
/>
<motion.line
x1="4" x2="20" y1="15" y2="15"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"
style={{ originX: "50%", originY: "50%" }}
initial={false}
animate={open ? { rotate: -45, y: -3 } : { rotate: 0, y: 0 }}
transition={transition}
/>
</svg>
);
}
/**
* A fullscreen navigation overlay on a native modal <dialog>. The background grows as a circle
* from the trigger, large links rise through masks with a stagger, and a secondary column fades in.
* showModal() provides the focus trap, Escape and inert page; focus returns to the trigger on close.
*/
export function OverlayMenu({
items,
currentHref,
open,
defaultOpen = false,
onOpenChange,
onNavigate,
closeOnNavigate = true,
aside,
reveal = "circle",
tone = "inverse",
showIndex = true,
duration = 700,
stagger = 60,
accent = "#FF7448",
title = "Menu",
openLabel = "Open menu",
closeLabel = "Close menu",
className = "",
}: OverlayMenuProps) {
const uid = React.useId();
const dialogId = `${uid}-dialog`;
const reduceMotion = usePrefersReducedMotion();
const triggerRef = React.useRef<HTMLButtonElement>(null);
const dialogRef = React.useRef<HTMLDialogElement>(null);
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isOpen = open ?? internalOpen;
const setOpen = (next: boolean) => {
if (open === undefined) setInternalOpen(next);
onOpenChange?.(next);
};
// Circle reveal geometry, written in effects (never during render).
const originX = useMotionValue(0);
const originY = useMotionValue(0);
const radius = useMotionValue(0);
const clipPath = useMotionTemplate`circle(${radius}px at ${originX}px ${originY}px)`;
const useCircle = reveal === "circle" && !reduceMotion;
const seconds = duration / 1000;
const runOpen = React.useEffectEvent(() => {
const trigger = triggerRef.current?.getBoundingClientRect();
const w = window.innerWidth;
const h = window.innerHeight;
const x = trigger ? trigger.left + trigger.width / 2 : w - 40;
const y = trigger ? trigger.top + trigger.height / 2 : 40;
originX.set(x);
originY.set(y);
// Radius that reaches the farthest viewport corner.
const r = Math.hypot(Math.max(x, w - x), Math.max(y, h - y)) + 8;
radius.set(0);
return animate(radius, r, useCircle ? { duration: seconds, ease: EASE } : { duration: 0 });
});
const runClose = React.useEffectEvent(() =>
animate(radius, 0, useCircle ? { duration: seconds * 0.75, ease: EASE, delay: 0.12 } : { duration: reduceMotion ? 0 : 0.3 }),
);
// Drive the native dialog from state: showModal → reveal; close → retract, then close().
React.useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (isOpen) {
if (!dialog.open) dialog.showModal();
const controls = runOpen();
return () => controls.stop();
}
if (dialog.open) {
let cancelled = false;
const controls = runClose();
controls.then(() => {
if (cancelled) return;
dialog.close();
triggerRef.current?.focus({ preventScroll: true });
});
return () => {
cancelled = true;
controls.stop();
};
}
}, [isOpen]);
// Body scroll lock while open, compensating for the scrollbar so the page does not shift.
React.useEffect(() => {
if (!isOpen) return;
const html = document.documentElement;
const body = document.body;
const scrollbar = window.innerWidth - html.clientWidth;
const prev = { overflow: html.style.overflow, paddingRight: body.style.paddingRight };
html.style.overflow = "hidden";
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
return () => {
html.style.overflow = prev.overflow;
body.style.paddingRight = prev.paddingRight;
};
}, [isOpen]);
const state = isOpen ? "open" : "closed";
const step = stagger / 1000;
const textDelay = useCircle ? seconds * 0.35 : 0.05;
const listVariants = {
open: { transition: { staggerChildren: reduceMotion ? 0 : step, delayChildren: reduceMotion ? 0 : textDelay } },
closed: { transition: { staggerChildren: reduceMotion ? 0 : step * 0.5, staggerDirection: -1 } },
};
const linkVariants = reduceMotion
? { open: { y: "0%", opacity: 1, transition: { duration: 0.2 } }, closed: { y: "0%", opacity: 0, transition: { duration: 0.15 } } }
: {
open: { y: "0%", opacity: 1, transition: { duration: 0.75, ease: EASE_OUT } },
closed: { y: "110%", opacity: 1, transition: { duration: 0.35, ease: EASE } },
};
const fadeVariants = {
open: { opacity: 1, y: 0, transition: { duration: reduceMotion ? 0.2 : 0.6, delay: reduceMotion ? 0 : textDelay + 0.15, ease: EASE_OUT } },
closed: { opacity: 0, y: reduceMotion ? 0 : 8, transition: { duration: 0.2 } },
};
const focusRing = "focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--om-accent)]";
const ringOnAccent = tone === "accent" ? "focus-visible:outline-current" : "";
return (
<>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={isOpen}
aria-controls={dialogId}
aria-label={openLabel}
onClick={() => setOpen(!isOpen)}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full border border-zinc-900/10 bg-white text-zinc-900 transition-colors hover:border-zinc-900/25 dark:border-white/10 dark:bg-zinc-900 dark:text-zinc-100 dark:hover:border-white/25 ${focusRing} focus-visible:outline-offset-2 ${className}`}
style={{ ["--om-accent" as string]: accent }}
>
<MorphIcon open={isOpen} reduceMotion={reduceMotion} />
</button>
<dialog
ref={dialogRef}
id={dialogId}
aria-label={title}
onCancel={(event) => {
// Escape: keep React state the source of truth so the exit animation can run.
event.preventDefault();
setOpen(false);
}}
onClose={() => {
if (isOpen) setOpen(false);
}}
className="m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 backdrop:bg-transparent"
style={{ ["--om-accent" as string]: accent }}
>
<motion.div
className={`absolute inset-0 flex flex-col overflow-y-auto overscroll-contain ${TONES[tone]}`}
style={useCircle ? { clipPath } : undefined}
initial={false}
animate={useCircle ? { opacity: 1 } : { opacity: isOpen ? 1 : 0 }}
transition={{ duration: reduceMotion ? 0.15 : seconds * 0.5 }}
>
<div className="flex shrink-0 items-center justify-between px-5 py-4 sm:px-8 sm:py-6">
<motion.p
className="font-mono text-[10px] uppercase tracking-[0.14em] opacity-60"
variants={fadeVariants}
initial="closed"
animate={state}
>
{title}
</motion.p>
<button
type="button"
aria-label={closeLabel}
onClick={() => setOpen(false)}
className={`inline-flex h-11 w-11 items-center justify-center rounded-full border border-current/20 transition-colors hover:bg-current/10 ${focusRing} focus-visible:outline-offset-2 ${ringOnAccent}`}
>
<MorphIcon open={isOpen} reduceMotion={reduceMotion} />
</button>
</div>
<div className="grid flex-1 grid-cols-1 gap-10 px-5 pb-10 sm:px-8 md:grid-cols-[minmax(0,1fr)_minmax(0,18rem)] md:items-end md:gap-16 md:pb-14">
<nav aria-label={title} className="min-w-0 self-center">
<motion.ul className="group/nav" variants={listVariants} initial="closed" animate={state}>
{items.map((item, index) => {
const current = item.href === currentHref;
return (
<li key={item.href}>
<a
href={item.href}
aria-current={current ? "page" : undefined}
onClick={(event) => {
onNavigate?.(item, event);
if (closeOnNavigate) setOpen(false);
}}
className={`block rounded-sm transition-opacity duration-300 group-hover/nav:opacity-40 hover:opacity-100! ${focusRing} ${ringOnAccent}`}
>
{/* The mask: the row rises from below this clipping box. */}
<span className="block overflow-hidden pb-[0.06em]">
<motion.span variants={linkVariants} className="flex items-baseline gap-3 sm:gap-5">
{showIndex && (
<span aria-hidden="true" className="w-6 shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] tabular-nums opacity-50 sm:w-8">
{String(index + 1).padStart(2, "0")}
</span>
)}
<span className="text-[clamp(2.4rem,7.5vw,5.75rem)] font-semibold leading-[1.02] tracking-[-0.035em]">
{item.label}
</span>
{current && (
<span
aria-hidden="true"
className={`h-2 w-2 shrink-0 self-center rounded-full ${tone === "accent" ? "bg-current" : "bg-[var(--om-accent)]"}`}
/>
)}
{item.meta && (
<span className="font-mono text-[10px] uppercase tracking-[0.14em] opacity-60">{item.meta}</span>
)}
</motion.span>
</span>
</a>
</li>
);
})}
</motion.ul>
</nav>
{aside && (
<motion.div className="hidden min-w-0 text-sm md:block" variants={fadeVariants} initial="closed" animate={state}>
<div className="border-t border-current/15 pt-5">{aside}</div>
</motion.div>
)}
</div>
</motion.div>
</dialog>
</>
);
}