Animated Nested Dropdown Menu
Animated multi-level dropdown menu with smooth sliding transitions, dynamic resizing, and nested navigation support inspired by Facebook
Loading demo...
What this component does
- Renders a list of menu items (some items have
children) - Clicking an item with
childrenpushes a new menu level - Clicking back pops to the previous level
- Transitions slide left/right depending on direction
- Dropdown container animates width + height based on the active level’s content
1) Menu data = a tree
const ITEM = [
{ id: 1, name: "...", children: [...] },
{ id: 5, name: "Log out" }
];Key idea
- This is a tree: each node may have
children - Only items with
childrenare “navigable” - Leaves (no children) are final actions
2) Types: “AnyItem” lets you store mixed depths
type ItemType = (typeof ITEM)[number];
type ChildItemType = NonNullable<ItemType["children"]>[number];
type AnyItem = ItemType | ChildItemType;Why this exists
navStackstores arrays of items at different depths- Those arrays can contain either root items or child items
- So you unify them under
AnyItem
(As written, it works for 2 levels cleanly; your data goes 3 levels deep — more on that below.)
3) Navigation state = stacks
navStack: history of menus (arrays)
const [navStack, setNavStack] = useState<AnyItem[][]>([ITEM]);- Always contains at least one entry: the root
ITEM - Each push adds a new level:
[..., children] - Current menu = last element
titleStack: history of titles
const [titleStack, setTitleStack] = useState<string[]>([]);- Stores labels used by the back row
- Current “back label” = last element
direction: controls slide direction
const [direction, setDirection] = useState(1); // 1 forward, -1 back- When entering submenu →
1 - When going back →
-1 - Used in animation variants
4) Deriving “where we are”
const currentItems = navStack[navStack.length - 1];
const currentTitle = titleStack[titleStack.length - 1];
const depth = navStack.length;currentItemsdrives the UI listdepthbecomes the animationkey(more below)
5) Click behavior = push vs pop
Push into submenu
const handleItemClick = (item: AnyItem) => {
if ("children" in item && item.children) {
setDirection(1);
setNavStack([...navStack, item.children as AnyItem[]]);
setTitleStack([...titleStack, item.name]);
}
};What’s happening
- Only items with
childrennavigate - You push
childrenarray tonavStack - You push the item name to
titleStack - Set direction forward so animation slides left
Pop back
const handleBack = () => {
if (navStack.length > 1) {
setDirection(-1);
setNavStack(navStack.slice(0, -1));
setTitleStack(titleStack.slice(0, -1));
}
};What’s happening
- Remove last menu level
- Remove last title
- Set direction backward so animation reverses
6) Container auto-resize
You use react-use-measure:
const [contentRef, contentBounds] = useMeasure();Then animate the container to match the measured content:
<motion.div
animate={{
width: contentBounds.width || "auto",
height: contentBounds.height || "auto",
}}
/>Why this matters
- Each submenu can be taller/shorter
- Without this, the container would jump in size
- Measurement turns “auto” sizing into numbers Motion can animate
7) Sliding between levels (direction-aware)
This is the “page transition” inside the dropdown:
Key detail: key={depth}
<motion.div key={depth} ... />Every time depth changes:
- React unmounts the previous “page”
- Motion runs exit animation for old page
- Motion runs enter animation for new page
Variants based on direction
variants={{
enter: (d) => ({ x: d > 0 ? "100%" : "-100%" }),
center: { x: 0 },
exit: (d) => ({ x: d > 0 ? "-100%" : "100%" }),
}}Meaning
- Forward navigation: new page slides in from right, old slides out left
- Back navigation: new page slides in from left, old slides out right
Why custom={direction}
custom injects direction into variants so they can decide which side to use.
8) Back row rendering
{
depth > 1 && (
<div onClick={handleBack}>
<span>{currentTitle}</span>
</div>
);
}What it does
- Only shows on submenu levels
- Uses
currentTitlefrom the stack (label of the submenu parent)
9) Item row rendering + “has children” indicator
{
currentItems.map((item) => (
<div onClick={() => handleItemClick(item)}>
...
{"children" in item && item.children && <ArrowRightIcon />}
</div>
));
}- Items are clickable regardless
- Only items with
childrencause navigation - Arrow renders only if
childrenexists
10) MotionConfig for shared transition
const TRANSITION = { type: "keyframes", duration: 0.1, ease: "easeInOut" };
<MotionConfig transition={TRANSITION}>Why it matters
- You don’t repeat transition props on every motion component
- All motion children inherit this default
One important note (your code vs your data depth)
Your AnyItem typing is basically “root or one-level child”.
But your data includes grandchildren:
Display & accessibility→Appearance→Dark mode / Light mode / System default
At runtime, it works because JS is permissive, but TS may start fighting you.
If you want proper typing for unlimited depth, you’d want a recursive type like:
type MenuNode = { id; name; icon; children?: MenuNode[] }
(No need to change now unless TS complains.)
Mental model
- Menu = tree
- UI = “current level” array
- Navigation = stacks (
pushinto children /popback) - Animation = keyed “pages” that slide in/out based on direction
- Container = measured and animated to fit current page