Navigation
MegaCanvas
One big panel serves every menu. It resizes to fit each section and the content slides in from the side you moved toward.
Download
- React
- Tailwind CSS v4
- motion
components/ui/mega-canvas.tsx
"use client";
import { useEffect, useId, useRef, useState, type FocusEvent, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type MegaCanvasLink = { label: string; href: string; note?: string };
export type MegaCanvasSection = {
label: string;
links: MegaCanvasLink[];
/** Optional block on the right of the panel, e.g. a promo card. */
feature?: ReactNode;
};
type MegaCanvasProps = {
sections: MegaCanvasSection[];
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (link: MegaCanvasLink, e: MouseEvent<HTMLAnchorElement>) => void;
/** Panel width in pixels. It shrinks to fit narrow spaces. */
width?: number;
/** Milliseconds to wait before closing after the pointer leaves. */
closeDelay?: number;
/** Colour of the note text and hover marker. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Bar look. */
className?: string;
/** Panel look. */
panelClassName?: string;
};
/* MegaCanvas: one big panel serves every menu. It resizes to each section and the content slides in from the side you moved toward. */
export function MegaCanvas({
sections,
onSelect,
width = 560,
closeDelay = 160,
accent = "#a78bfa",
label = "Main",
className = "rounded-full border border-white/10 bg-white/5 p-1 text-sm text-white backdrop-blur-md",
panelClassName = "rounded-2xl border border-white/10 bg-[#141026]/95 text-white shadow-2xl shadow-black/50 backdrop-blur-xl",
}: MegaCanvasProps) {
const reduce = useReducedMotion();
const uid = useId();
const nav = useRef<HTMLElement>(null);
const panel = useRef<HTMLDivElement>(null);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const [open, setOpen] = useState<number | null>(null);
// +1 when moving right, -1 when moving left: content slides in from that side.
const [dir, setDir] = useState(0);
const focusPanel = useRef(false);
const pointer = useRef("");
useEffect(() => () => clearTimeout(timer.current), []);
// After a click / Enter, move focus into the panel.
useEffect(() => {
if (!focusPanel.current || open === null) return;
focusPanel.current = false;
// Only the incoming section: the old one is still fading out.
panel.current?.querySelector<HTMLElement>(`[data-mega-section="${open}"] a[href]`)?.focus();
}, [open]);
const show = (i: number) => {
clearTimeout(timer.current);
setDir(open === null ? 0 : Math.sign(i - open));
setOpen(i);
};
const hideSoon = () => {
clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(null), closeDelay);
};
const closeTo = (i: number | null) => {
setOpen(null);
if (i !== null) nav.current?.querySelectorAll<HTMLButtonElement>("[data-mega-trigger]")[i]?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "Escape" && open !== null) closeTo(open);
};
// Close once focus leaves the whole menu.
const onBlur = (e: FocusEvent<HTMLElement>) => {
if (!nav.current?.contains(e.relatedTarget as Node)) setOpen(null);
};
const section = open === null ? null : sections[open];
return (
<nav
ref={nav}
aria-label={label}
onPointerLeave={hideSoon}
onKeyDown={onKeyDown}
onBlur={onBlur}
className="@container relative flex w-full justify-center"
>
<ul className={`flex items-center gap-1 ${className}`}>
{sections.map((s, i) => (
<li key={s.label} className="relative">
<button
type="button"
data-mega-trigger
aria-expanded={open === i}
aria-controls={`${uid}-panel`}
onPointerEnter={(e) => e.pointerType === "mouse" && show(i)}
onPointerDown={(e) => (pointer.current = e.pointerType)}
onClick={(e) => {
// A mouse already opened it on hover, so its click keeps it open. Touch / keyboard toggle.
const mouse = e.detail > 0 && pointer.current === "mouse";
if (open === i) {
if (!mouse) closeTo(i);
return;
}
if (e.detail === 0) focusPanel.current = true; // keyboard: move into the panel
show(i);
}}
className="relative flex items-center gap-1 rounded-full px-3 py-1.5 outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{open === i && (
<motion.span
layoutId={`${uid}-hl`}
aria-hidden
className="absolute inset-0 rounded-full bg-white/10"
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 36 }}
/>
)}
<span className="relative">{s.label}</span>
<svg
aria-hidden
viewBox="0 0 10 10"
className={`relative size-2.5 opacity-60 transition-transform duration-200 ${open === i ? "rotate-180" : ""}`}
>
<path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</li>
))}
</ul>
<AnimatePresence>
{section && (
<motion.div
key="panel"
ref={panel}
id={`${uid}-panel`}
onPointerEnter={() => clearTimeout(timer.current)}
initial={{ opacity: 0, y: reduce ? 0 : -8, scale: reduce ? 1 : 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -8, scale: reduce ? 1 : 0.97 }}
transition={{ duration: reduce ? 0 : 0.2, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-full z-30 pt-3"
style={{ width: `min(${width}px, 100cqw)`, transformOrigin: "top center" }}
>
<motion.div
layout={!reduce}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className={`overflow-hidden ${panelClassName}`}
>
<AnimatePresence initial={false} mode="popLayout" custom={dir}>
<motion.div
key={open}
data-mega-section={open}
custom={dir}
variants={{
enter: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * 48 }),
center: { opacity: 1, x: 0 },
leave: (d: number) => ({ opacity: 0, x: reduce ? 0 : d * -48 }),
}}
initial="enter"
animate="center"
exit="leave"
transition={{ duration: reduce ? 0 : 0.25, ease: [0.22, 1, 0.36, 1] }}
className={`grid gap-4 p-4 ${section.feature ? "@md:grid-cols-[1fr_200px]" : ""}`}
>
<ul className="grid gap-1 @md:grid-cols-2">
{section.links.map((link) => (
<li key={link.href}>
<a
href={link.href}
onClick={(e) => {
onSelect?.(link, e);
setOpen(null);
}}
className="group block rounded-xl px-3 py-2 outline-none hover:bg-white/5 focus-visible:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/70"
>
<span className="flex items-center gap-2 text-sm font-medium">
{link.label}
<span
aria-hidden
className="-translate-x-1 opacity-0 transition duration-200 group-hover:translate-x-0 group-hover:opacity-100 group-focus-visible:translate-x-0 group-focus-visible:opacity-100"
style={{ color: accent }}
>
→
</span>
</span>
{link.note && <span className="mt-0.5 block text-xs opacity-55">{link.note}</span>}
</a>
</li>
))}
</ul>
{section.feature && <div className="min-w-0">{section.feature}</div>}
</motion.div>
</AnimatePresence>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</nav>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/mega-canvas.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/mega-canvas-demo.tsx
"use client"; import { MegaCanvas } from "@/components/ui/mega-canvas"; const SECTIONS = [ { label: "Product", links: [ { label: "Editor", href: "#editor", note: "Design in the browser" }, { label: "Prototyping", href: "#proto", note: "Click-through flows" }, { label: "Handoff", href: "#handoff", note: "Specs devs can read" }, { label: "Plugins", href: "#plugins", note: "Extend everything" }, ], feature: ( <div className="h-full rounded-xl bg-gradient-to-br from-[#a78bfa]/30 to-[#22d3ee]/20 p-3 text-xs"> <p className="font-medium text-white">New: Live cursors</p> <p className="mt-1 text-white/60">See your team move in real time.</p> </div> ), }, { label: "Solutions", links: [ { label: "Startups", href: "#startups" }, { label: "Agencies", href: "#agencies" }, { label: "Enterprise", href: "#enterprise", note: "SSO, audit logs" }, ], }, { label: "Resources", links: [ { label: "Docs", href: "#docs", note: "Guides and API" }, { label: "Blog", href: "#blog" }, { label: "Community", href: "#community", note: "Ask and share" }, { label: "Changelog", href: "#changelog" }, { label: "Templates", href: "#templates" }, { label: "Events", href: "#events" }, ], }, ]; export default function MegaCanvasExample() { return ( <div className="h-full bg-[#0b0a14] p-4 pt-6"> <MegaCanvas sections={SECTIONS} width={520} onSelect={(_, e) => e.preventDefault()} /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| sections | { label, links: { label, href, note? }[], feature? }[] | — | Menus. feature = optional block on the right (e.g. a promo card). |
| onSelect | (link, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| width | number | 560 | Panel width in pixels. Shrinks to fit narrow spaces. |
| closeDelay | number | 160 | Milliseconds before closing after the pointer leaves. |
| accent | string | "#a78bfa" | Hover arrow colour. |
| label | string | "Main" | Name read out by screen readers. |
| className | string | glass pill | Bar look. |
| panelClassName | string | dark glass panel | Panel look. |