Nested Menu Drill
A submenu pushes the parent list aside inside the same panel, and the panel resizes to fit it.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Nested Menu Drill
*
* A submenu opens inside the menu it came from: the parent list slides
* out to the left, the child arrives from the right, and the panel
* resizes to fit. One surface, one place to look.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so the menu reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `title`.
* Press a row with a chevron; the back row returns.
* Requires the automatic JSX runtime (default since React 17).
*/
export type NestedMenuDrillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Heading of the root level. */
title?: string;
/** Fires with the chosen leaf item's label. */
onSelect?: (label: string) => void;
};
type VariantConfig = {
/** How far a level travels as it enters or leaves, in px. */
travel: number;
/** Seconds for the outgoing level to clear. */
exit: number;
/** Seconds for the panel to resize between levels. */
resize: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the panel's height is a real size change, so it runs as a
// short eased tween — a spring on height would overshoot the menu past its
// own resting size and bounce the rows inside it. The levels themselves
// travel on springs at or above a 0.8 damping ratio, and they only
// translate: menu rows are text, and text that lands twice is unreadable.
// Variants change distance and pace, never the settle.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short push. For a dense app menu opened many times an hour.
subtle: {
travel: 18,
exit: 0.11,
resize: 0.16,
spring: { type: "spring", stiffness: 620, damping: 46 },
},
// Enough travel to read the direction of the drill. All-purpose.
default: {
travel: 34,
exit: 0.14,
resize: 0.22,
spring: { type: "spring", stiffness: 470, damping: 38 },
},
// A full-width push and a slower resize, so the levels read as pages —
// for a mobile-width menu.
playful: {
travel: 52,
exit: 0.17,
resize: 0.28,
spring: { type: "spring", stiffness: 380, damping: 33 },
},
};
const ACCENT = "#7C7CF0";
const DANGER = "#E05260";
/** 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 border or hover fill that is
* correctly toned in either theme. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const ROW_HEIGHT = 34;
const BACK_HEIGHT = 35;
const LIST_PADDING = 12;
type MenuNode = { label: string; danger?: boolean; children?: MenuNode[] };
const MENU: MenuNode[] = [
{
label: "Move to",
children: [{ label: "Reports" }, { label: "Finance" }, { label: "Team share" }],
},
{
label: "Share",
children: [
{ label: "Copy link" },
{ label: "Invite people" },
{ label: "Publish to web" },
],
},
{
label: "Export as",
children: [{ label: "PDF document" }, { label: "CSV data" }, { label: "PNG image" }],
},
{ label: "Rename" },
{ label: "Delete", danger: true },
];
export default function NestedMenuDrill({
variant = "default",
title = "Q3 revenue summary",
onSelect,
}: NestedMenuDrillProps) {
const [path, setPath] = useState<number[]>([]);
const [direction, setDirection] = useState(1);
const [chosen, setChosen] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The path is a list of indices, so the current level is a walk rather
// than a copy — nothing about the menu is duplicated per level.
let items = MENU;
let heading = title;
for (const index of path) {
const node: MenuNode | undefined = items[index];
if (!node?.children) break;
heading = node.label;
items = node.children;
}
const atRoot = path.length === 0;
const levelHeight =
LIST_PADDING + items.length * ROW_HEIGHT + (atRoot ? 0 : BACK_HEIGHT);
const drill = (index: number) => {
setDirection(1);
setPath([...path, index]);
};
const back = () => {
setDirection(-1);
setPath(path.slice(0, -1));
};
const choose = (label: string) => {
setChosen(label);
onSelect?.(label);
};
// Reduced motion: the levels still change and the heading still says
// where you are — they simply cross-fade instead of sliding.
const travel = reduceMotion ? 0 : cfg.travel;
const levelVariants = {
enter: (dir: number) => ({ x: dir * travel, opacity: 0 }),
center: {
x: 0,
opacity: 1,
transition: reduceMotion
? { duration: 0.14, ease: "easeOut" as const }
: { ...cfg.spring, opacity: { duration: 0.16, ease: "easeOut" as const } },
},
exit: (dir: number) => ({
x: -dir * travel,
opacity: 0,
transition: { duration: reduceMotion ? 0.1 : cfg.exit, ease: "easeIn" as const },
}),
};
return (
<div
style={{
width: 272,
borderRadius: 14,
// A menu is opaque by definition: it covers whatever it is opened
// over, and here the two levels also cross over each other
// mid-drill. `Canvas`/`CanvasText` are the CSS system colors for
// page background and page text, so the panel lands light in a
// light app and dark in a dark one, and everything inside then
// mixes from `currentColor`.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(14)}`,
boxShadow: "0 16px 38px rgba(0,0,0,0.26)",
overflow: "hidden",
}}
>
<div
style={{
padding: "10px 12px 9px",
borderBottom: `1px solid ${tone(10)}`,
fontSize: 11.5,
opacity: 0.5,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{chosen ? `Selected: ${chosen}` : `Document · ${title}`}
</div>
{/* The panel resizes to whichever level is on screen, so the drill
never leaves a short list padded out with empty menu. */}
<motion.div
initial={false}
animate={{ height: levelHeight }}
transition={{
duration: reduceMotion ? 0 : cfg.resize,
ease: "easeOut",
}}
style={{ position: "relative", overflow: "hidden" }}
>
<AnimatePresence mode="sync" custom={direction} initial={false}>
<motion.div
key={path.join("-") || "root"}
custom={direction}
variants={levelVariants}
initial="enter"
animate="center"
exit="exit"
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
// Each level carries the panel's own surface: mid-drill the
// two levels overlap, and a translucent level would show the
// other one straight through it.
background: "Canvas",
padding: `${LIST_PADDING / 2}px 0`,
}}
>
{!atRoot && (
<button
type="button"
onClick={back}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
height: BACK_HEIGHT - 1,
padding: "0 12px",
marginBottom: 1,
border: 0,
borderBottom: `1px solid ${tone(10)}`,
background: "none",
color: "inherit",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 650,
textAlign: "left",
cursor: "pointer",
}}
>
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke={ACCENT}
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M9.8 3.6 5.4 8l4.4 4.4" />
</svg>
<span
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{heading}
</span>
</button>
)}
{items.map((item, index) => (
<button
key={item.label}
type="button"
onClick={() => (item.children ? drill(index) : choose(item.label))}
aria-haspopup={item.children ? "menu" : undefined}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
height: ROW_HEIGHT,
padding: "0 12px",
border: 0,
background: "none",
color: item.danger ? DANGER : "inherit",
fontFamily: "inherit",
fontSize: 12.5,
fontWeight: 550,
textAlign: "left",
cursor: "pointer",
}}
>
<span
style={{
flex: 1,
minWidth: 0,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{item.label}
</span>
{item.children && (
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.45"
aria-hidden
>
<path d="M6.2 3.6 10.6 8l-4.4 4.4" />
</svg>
)}
</button>
))}
</motion.div>
</AnimatePresence>
</motion.div>
</div>
);
}About this pattern
The alternative to flying a second menu out of the side of the first, which on a narrow screen has nowhere to go. Here the drill happens in place: the parent list leaves to the left, the child arrives from the right, the heading becomes a back row, and the panel resizes to whatever the new level actually needs so a three-item submenu is not padded out to the height of a five-item parent. Height is the one genuine size change, so it is a short eased tween — a spring there would overshoot the menu past its own resting size and bounce every row inside it. The levels themselves only translate, never scale, because menu rows are text end to end.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Mobile navigation
Each level pushes the previous one aside and offers a back row to return.
Related patterns
- Breadcrumb Trail AppendGoing one level deeper slides a new crumb in from the right while the trail behind it recedes.
- Sidebar CollapseA navigation rail narrows to icons, with labels leaving before the width closes so text is never squeezed.
- Split View ResizeDragging the divider resizes both panes live, and releasing it settles the split on the nearest stop.