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 children pushes 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 children are “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

  • navStack stores 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

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;
  • currentItems drives the UI list
  • depth becomes the animation key (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 children navigate
  • You push children array to navStack
  • 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 currentTitle from 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 children cause navigation
  • Arrow renders only if children exists

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 & accessibilityAppearanceDark 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 (push into children / pop back)
  • Animation = keyed “pages” that slide in/out based on direction
  • Container = measured and animated to fit current page