Navigation
MorphMenu
The round menu button itself stretches into the menu panel while its two lines turn into a cross. Esc, a click outside or a link closes it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/morph-menu.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type MouseEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type MorphMenuItem = { label: string; href: string };
type MorphMenuProps = {
items: MorphMenuItem[];
/** Called on click. Call e.preventDefault() to stop the browser following the link. */
onSelect?: (item: MorphMenuItem, e: MouseEvent<HTMLAnchorElement>) => void;
/** Width of the open panel in pixels. */
width?: number;
/** Size of the closed button in pixels. */
size?: number;
/** Which side the panel grows from. */
align?: "left" | "right";
/** Colour of the hover marker. */
accent?: string;
/** Name read out by screen readers. */
label?: string;
/** Look of the button / panel. */
className?: string;
};
/* MorphMenu: the round menu button itself stretches into the menu panel, and the lines turn into a cross. */
export function MorphMenu({
items,
onSelect,
width = 240,
size = 48,
align = "right",
accent = "#a78bfa",
label = "Menu",
className = "border border-white/10 bg-[#161229] text-white shadow-2xl shadow-black/50",
}: MorphMenuProps) {
const reduce = useReducedMotion();
const uid = useId();
const wrap = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 380, damping: 32 };
// Close on a click outside.
useEffect(() => {
if (!open) return;
const away = (e: PointerEvent) => {
if (!wrap.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("pointerdown", away);
return () => document.removeEventListener("pointerdown", away);
}, [open]);
const close = () => {
setOpen(false);
button.current?.focus();
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape" && open) close();
};
return (
<div ref={wrap} className="relative inline-block" style={{ width: size, height: size }} onKeyDown={onKeyDown}>
<motion.div
initial={false}
animate={{
width: open ? width : size,
height: open ? "auto" : size,
borderRadius: open ? 20 : size / 2,
}}
transition={spring}
className={`absolute top-0 z-20 overflow-hidden ${align === "right" ? "right-0" : "left-0"} ${className}`}
style={{ minHeight: size }}
>
<button
ref={button}
type="button"
aria-expanded={open}
aria-controls={`${uid}-menu`}
aria-label={open ? "Close menu" : "Open menu"}
onClick={() => setOpen((o) => !o)}
className={`absolute top-0 z-10 grid place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${align === "right" ? "right-0" : "left-0"}`}
style={{ width: size, height: size }}
>
<motion.span
aria-hidden
className="absolute h-0.5 w-5 rounded-full bg-current"
initial={false}
animate={open ? { y: 0, rotate: 45 } : { y: -4, rotate: 0 }}
transition={spring}
/>
<motion.span
aria-hidden
className="absolute h-0.5 w-5 rounded-full bg-current"
initial={false}
animate={open ? { y: 0, rotate: -45 } : { y: 4, rotate: 0 }}
transition={spring}
/>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.nav
id={`${uid}-menu`}
aria-label={label}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: reduce ? 0 : 0.1 } }}
transition={{ duration: reduce ? 0 : 0.2, delay: reduce ? 0 : 0.08 }}
style={{ width, paddingTop: size }}
className="px-3 pb-3"
>
<p className="px-3 pb-2 font-mono text-[11px] uppercase tracking-[0.2em] opacity-50">{label}</p>
<ul className="grid">
{items.map((item, i) => (
<motion.li
key={item.href}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { delay: 0.1 + i * 0.04, duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
<a
href={item.href}
onClick={(e) => {
onSelect?.(item, e);
close();
}}
className="group flex items-center gap-3 rounded-xl px-3 py-2 text-lg outline-none hover:bg-white/5 focus-visible:bg-white/5 focus-visible:ring-2 focus-visible:ring-white/70"
>
<span
aria-hidden
className="h-1.5 w-1.5 scale-0 rounded-full transition-transform duration-200 group-hover:scale-100 group-focus-visible:scale-100"
style={{ background: accent }}
/>
<span className="-ml-[18px] transition-[margin] duration-200 group-hover:ml-0 group-focus-visible:ml-0">
{item.label}
</span>
</a>
</motion.li>
))}
</ul>
</motion.nav>
)}
</AnimatePresence>
</motion.div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/morph-menu.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/morph-menu-demo.tsx
"use client"; import { MorphMenu } from "@/components/ui/morph-menu"; const ITEMS = [ { label: "Work", href: "#work" }, { label: "Studio", href: "#studio" }, { label: "Journal", href: "#journal" }, { label: "Contact", href: "#contact" }, ]; export default function MorphMenuExample() { return ( <div className="relative h-full bg-[radial-gradient(circle_at_80%_0%,#2a2150,#0b0a14_65%)] p-5"> <div className="flex items-start justify-between"> <span className="pt-3 text-sm font-medium text-white">Atelier</span> <MorphMenu items={ITEMS} onSelect={(_, e) => e.preventDefault()} /> </div> <p className="mt-16 text-center text-xs text-white/40">Press the button: it becomes the menu.</p> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| items | { label, href }[] | — | The links. |
| onSelect | (item, e) => void | — | Click handler. Call e.preventDefault() to stay on the page. |
| width | number | 240 | Width of the open panel in pixels. |
| size | number | 48 | Size of the closed button in pixels. |
| align | "left" | "right" | "right" | Which side the panel grows from. |
| accent | string | "#a78bfa" | Hover dot colour. |
| label | string | "Menu" | Panel heading and screen reader name. |
| className | string | dark panel | Look of the button / panel. |