Mobile Menu Fullscreen
A hamburger becomes a close glyph as a full-screen menu wipes in with staggered links.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Mobile Menu Fullscreen
*
* The hamburger becomes a close glyph while a full-height menu comes down
* over the page and the links arrive behind it. The header stays above
* the panel the whole time, so the control you pressed is the control you
* press to leave — the motion never takes the exit away from you.
*
* Self-contained: depends only on `motion` (react ships with your app).
* The page is mixed from the inherited text color and the menu uses the
* CSS system colors, so both read correctly on a light page and a dark one.
* Works with zero props; tune via `variant`, `promoImageSrc`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type MobileMenuFullscreenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Real photograph for the promo tile under the header; omit for the
* gradient stand-in. */
promoImageSrc?: string;
/** Notified whenever the menu opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
spring: { type: "spring"; stiffness: number; damping: number };
/** Seconds between one link and the next. */
stagger: number;
/** px each link rises from as it arrives. */
lift: number;
/** Seconds the panel takes to leave. */
exit: number;
};
// Quality rule: the panel is the full frame, and a full-frame surface
// that overshoots looks like a dropped object. Springs sit at or above a
// 0.8 damping ratio (ζ = damping / 2√stiffness). The links translate and
// fade — they never scale, because scaling 18px type on arrival is the
// difference between a menu and a toy.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.00 — arrives flat, links almost together. For utility apps.
subtle: {
spring: { type: "spring", stiffness: 480, damping: 44 },
stagger: 0.028,
lift: 6,
exit: 0.2,
},
// ζ ≈ 0.90 — one soft settle and a readable cascade behind it.
default: {
spring: { type: "spring", stiffness: 380, damping: 35 },
stagger: 0.045,
lift: 12,
exit: 0.24,
},
// ζ ≈ 0.81 — a longer drop and a longer cascade, for a brand-led site.
playful: {
spring: { type: "spring", stiffness: 300, damping: 28 },
stagger: 0.062,
lift: 18,
exit: 0.26,
},
};
const ACCENT = "#7C7CF0";
/** Theme-adaptive neutral: `currentColor` is the text color this component
* inherits — near-black on a light page, near-white on a dark one — so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const LINKS = [
{ label: "Shop", meta: "218 items" },
{ label: "New arrivals", meta: "12 this week" },
{ label: "Journal", meta: "" },
{ label: "Stores", meta: "" },
{ label: "Support", meta: "" },
] as const;
export default function MobileMenuFullscreen({
variant = "default",
promoImageSrc,
onOpenChange,
}: MobileMenuFullscreenProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [open, setOpen] = useState(false);
const uid = useId();
const setMenu = (next: boolean) => {
setOpen(next);
onOpenChange?.(next);
};
useEffect(() => {
if (!open) return;
const onKey = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
setOpen(false);
onOpenChange?.(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onOpenChange]);
// Reduced motion: the menu is simply there, links and all. Nothing
// travels, and the glyph still says which state you are in.
const panelMotion = reduceMotion
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, transition: { duration: 0.12 } },
transition: { duration: 0.16, ease: "easeOut" as const },
}
: {
initial: { y: "-100%" },
animate: { y: "0%" },
// Leaving is quicker than arriving and plainly eased: a menu that
// exits as slowly as it entered feels reluctant to let you go.
exit: { y: "-100%", transition: { duration: cfg.exit, ease: "easeIn" as const } },
transition: cfg.spring,
};
return (
<div
style={{
position: "relative",
width: 244,
// 364, not 302: the panel's own content is 284px tall (five links
// at 50px plus the 34px action row) against 52px/16px padding, so
// at 302 the "Track an order" button and the locale line were
// laid out below the frame and clipped away by `overflow: hidden`
// — present in the DOM, never once on screen. This leaves 12px of
// slack, so a font metric shifting under a different stack does
// not silently eat the action row again.
height: 364,
borderRadius: 22,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
boxShadow: "0 16px 40px rgba(0,0,0,0.18)",
// The menu covers this frame rather than the viewport, so the
// pattern can live in a card or a device mock. In an app, make the
// panel `position: fixed` with inset 0, lock body scroll while it
// is open, and keep the header above it at a higher z-index.
overflow: "hidden",
}}
>
{/* The page underneath. It does not move: the panel is opaque, and
animating something nobody can see is just work. */}
<div style={{ padding: "52px 14px 0" }}>
<div
aria-hidden
style={{
position: "relative",
height: 96,
borderRadius: 14,
overflow: "hidden",
// Stands in for a photograph, not a UI surface — fixed colors
// in both themes, exactly as a real image would be.
background: "linear-gradient(145deg, #2F3E6B 0%, #6274B8 55%, #D8B79A 100%)",
}}
>
{promoImageSrc ? (
<img
src={promoImageSrc}
alt=""
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
) : (
<svg viewBox="0 0 216 96" width="100%" height="100%" style={{ display: "block" }}>
<circle cx="172" cy="26" r="24" fill="rgba(255,255,255,0.2)" />
<path d="M0 84 L44 58 L86 76 L132 44 L216 78 L216 96 L0 96 Z" fill="rgba(10,14,26,0.32)" />
</svg>
)}
</div>
<div style={{ fontSize: 14, fontWeight: 650, marginTop: 12 }}>
Late summer edit
</div>
<div style={{ fontSize: 12, opacity: 0.55, marginTop: 3, lineHeight: 1.5 }}>
Twelve new pieces, restocked weekly. Free returns for thirty days.
</div>
<div style={{ display: "flex", gap: 8, marginTop: 12 }}>
{["Outerwear", "Knitwear"].map((chip) => (
<span
key={chip}
style={{
padding: "6px 10px",
borderRadius: 999,
fontSize: 11.5,
background: tone(8),
border: `1px solid ${tone(12)}`,
}}
>
{chip}
</span>
))}
</div>
</div>
<AnimatePresence>
{open && (
<motion.nav
key="menu"
id={`${uid}-menu`}
aria-label="Main"
{...panelMotion}
style={{
position: "absolute",
inset: 0,
zIndex: 4,
display: "flex",
flexDirection: "column",
padding: "52px 16px 16px",
// A full-cover panel cannot be translucent: it would read as
// a tint over the page rather than a place of its own.
// `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text, so the menu lands light in a
// light app and dark in a dark one, and every tone() inside
// is then mixed from CanvasText.
background: "Canvas",
color: "CanvasText",
}}
>
<motion.ul
initial="closed"
animate="open"
exit="closed"
variants={{
open: {
transition: {
// The links wait for the panel to be most of the way
// down, then follow it in.
delayChildren: reduceMotion ? 0 : cfg.stagger * 2,
staggerChildren: reduceMotion ? 0 : cfg.stagger,
},
},
closed: { transition: { staggerChildren: 0 } },
}}
style={{
flex: 1,
listStyle: "none",
margin: 0,
padding: 0,
}}
>
{LINKS.map((link) => (
<motion.li
key={link.label}
variants={{
closed: { opacity: 0, y: reduceMotion ? 0 : cfg.lift },
open: { opacity: 1, y: 0 },
}}
transition={{
duration: reduceMotion ? 0 : 0.32,
ease: [0.22, 0.68, 0.3, 1],
}}
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
padding: "11px 2px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: "-0.01em" }}>
{link.label}
</span>
{link.meta && (
<span style={{ fontSize: 11, opacity: 0.45 }}>{link.meta}</span>
)}
</motion.li>
))}
</motion.ul>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
style={{
padding: "8px 12px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
background: ACCENT,
color: "#ffffff",
}}
>
Track an order
</span>
<span style={{ fontSize: 11.5, opacity: 0.5 }}>EN · EUR</span>
</div>
</motion.nav>
)}
</AnimatePresence>
{/* The header sits above the panel at all times. Whatever the menu
is doing, the control that closes it is exactly where the control
that opened it was. */}
<header
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 5,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "14px 14px 0 16px",
}}
>
<span style={{ fontSize: 13, fontWeight: 700, letterSpacing: "-0.01em" }}>
Northwind
</span>
<button
type="button"
onClick={() => setMenu(!open)}
aria-expanded={open}
aria-controls={`${uid}-menu`}
aria-label={open ? "Close menu" : "Open menu"}
style={{
display: "grid",
placeItems: "center",
width: 30,
height: 30,
borderRadius: 9,
border: 0,
background: "transparent",
color: "inherit",
cursor: "pointer",
}}
>
<span
aria-hidden
style={{
position: "relative",
display: "block",
width: 17,
height: 12,
}}
>
{/* Three bars become two crossed ones: the same strokes
rotating, so the glyph is one object changing state rather
than two icons swapping places. */}
{[0, 1, 2].map((bar) => (
<motion.span
key={bar}
initial={false}
animate={{
y: open ? (bar === 0 ? 5.5 : bar === 2 ? -5.5 : 0) : 0,
rotate: open ? (bar === 0 ? 45 : bar === 2 ? -45 : 0) : 0,
opacity: open && bar === 1 ? 0 : 1,
scaleX: open && bar === 1 ? 0.4 : 1,
}}
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 420, damping: 36 }
}
style={{
position: "absolute",
left: 0,
top: bar * 5.5,
width: "100%",
height: 1.8,
borderRadius: 2,
background: "currentColor",
}}
/>
))}
</span>
</button>
</header>
</div>
);
}About this pattern
Small-screen navigation, where a menu taking the whole frame is the honest amount of space to ask for. The structural decision is the header: it stays above the panel throughout, so the control you pressed to open is exactly where the control to close is, and the glyph morphs in place instead of being replaced by a different icon somewhere else. The panel comes down as one opaque surface on a near-critically-damped spring, the links follow a beat behind it with a parent-level stagger, and leaving is quicker than arriving — a menu that exits as slowly as it entered feels reluctant. The links translate and fade; nothing scales, because scaling 18px type on arrival is the difference between a menu and a toy.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
The menu control becomes a close glyph while a full-height panel covers the page.
Related patterns
- Bottom Sheet PresentA sheet rises from the bottom edge over a dimming backdrop and settles once.
- Tab Bar Icon SelectThe chosen icon fills and lifts while its label brightens, and the icon you left releases.
- Command Palette OpenThe palette drops a short distance into place with its results already filtering as it lands.