Components

Unlimited Nested Menu

The main dropdown never changes. Click a branch and its children open as a new panel right under that item, stacked over the menu below it - the clicked item's name becomes the title, sitting exactly where the row was, so the item appears to turn into the sub-menu's header. Do it again and the grandchildren stack the same way, with no depth limit - the folder path here runs seven levels deep. Parents stay behind, dimmed, iOS-style; click one to pop back to it. It is a real menu: ↑↓ move, → / Enter opens a sub-panel, ← / Esc pops one level, and focus lands on the first item of each new panel. Opening is a shared-element morph: the row itself becomes the header - its label turns bold as the panel grows out of it; closing is a quick, quiet fade back. Tick Instant for the jump cut.

Closed · click Move file and drill into a folder

Install

npx moumenlab add unlimited-nested-menu

For AI

Open .md

Usage

"use client";

import type { ReactNode } from "react";
import NestedMenu, { type NestedMenuItem } from "./unlimited-nested-menu";

export default function NestedMenuExample() {
  return (
    <div className="flex flex-wrap items-start gap-4">
      {/* Default: drill any branch and its children stack over the menu. */}
      <NestedMenu
        items={MENU_ITEMS}
        rootTitle="Actions"
        triggerLabel="Move file"
        onSelect={(item, path) => console.log("picked", item.label, "from", path.join(" › "))}
      />

      {/* align="end" pins the popup's right edge to the trigger (menus near the
          screen's right side); dim={false} keeps parent panels at full strength. */}
      <NestedMenu items={MENU_ITEMS} align="end" dim={false} triggerLabel="Options" />

      {/* animate={false}: instant open/drill/close, for dense tooling UIs. It can
          also be controlled - pass `open` + `onOpenChange`. */}
      <NestedMenu items={MENU_ITEMS} animate={false} triggerLabel="Quick menu" />
    </div>
  );
}

// A realistic "act on this file" menu: a deep folder tree (proves the depth is
// unbounded) plus sibling actions. A folder WITH children is a branch (opens a
// sub-panel); without, a leaf (a pickable target).
export const MENU_ITEMS: NestedMenuItem[] = [
  {
    id: "move", label: "Move to", icon: <FolderIcon />, items: [
      {
        id: "workspace", label: "Workspace", icon: <GridIcon />, items: [
          {
            id: "design", label: "Design", icon: <PenIcon />, items: [
              {
                id: "brand", label: "Brand", icon: <SparkleIcon />, items: [
                  {
                    id: "y2026", label: "2026", icon: <CalendarIcon />, items: [
                      {
                        id: "q1", label: "Q1 campaign", icon: <MegaphoneIcon />, items: [
                          { id: "launch", label: "Launch assets", icon: <RocketIcon /> },
                          { id: "social", label: "Social", icon: <ShareNodesIcon /> },
                          { id: "press", label: "Press kit", icon: <FileTextIcon /> },
                        ],
                      },
                      { id: "q2", label: "Q2 campaign", icon: <TargetIcon /> },
                    ],
                  },
                  { id: "logos", label: "Logos", icon: <ImageIcon /> },
                ],
              },
              { id: "productui", label: "Product UI", icon: <LayoutIcon /> },
            ],
          },
          {
            id: "eng", label: "Engineering", icon: <CodeIcon />, items: [
              { id: "frontend", label: "Frontend", icon: <MonitorIcon /> },
              { id: "backend", label: "Backend", icon: <DatabaseIcon /> },
            ],
          },
          { id: "personal", label: "Personal", icon: <UserIcon /> },
        ],
      },
    ],
  },
  {
    id: "status", label: "Set status", icon: <FlagIcon />, items: [
      { id: "todo", label: "Todo" },
      { id: "doing", label: "In progress" },
      { id: "review", label: "In review" },
      { id: "done", label: "Done" },
    ],
  },
  {
    id: "assign", label: "Assign to", icon: <UserIcon />, items: [
      { id: "sarah", label: "Sarah Chen" },
      { id: "marcus", label: "Marcus Lee" },
      { id: "priya", label: "Priya Patel" },
      { id: "you", label: "You" },
    ],
  },
  { id: "copy", label: "Copy link", icon: <LinkIcon />, hint: "⌘L" },
  { id: "dupe", label: "Duplicate", icon: <CopyIcon />, hint: "⌘D" },
  { id: "delete", label: "Delete", icon: <TrashIcon />, danger: true, hint: "⌫" },
];

function Svg({ children }: { children: ReactNode }) {
  return (
    <svg width={16} height={16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      {children}
    </svg>
  );
}

function FolderIcon() { return <Svg><path d="M4 20h16a1 1 0 0 0 1-1V8a1 1 0 0 0-1-1h-7.5l-2-2H4a1 1 0 0 0-1 1v13a1 1 0 0 0 1 1Z" /></Svg>; }
function FlagIcon() { return <Svg><path d="M4 22V4M4 4h11l-1.5 3.5L15 11H4" /></Svg>; }
function UserIcon() { return <Svg><circle cx="12" cy="8" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></Svg>; }
function LinkIcon() { return <Svg><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></Svg>; }
function CopyIcon() { return <Svg><rect x="9" y="9" width="11" height="11" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></Svg>; }
function TrashIcon() { return <Svg><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></Svg>; }
function GridIcon() { return <Svg><rect x="3" y="3" width="7" height="7" rx="1.5" /><rect x="14" y="3" width="7" height="7" rx="1.5" /><rect x="3" y="14" width="7" height="7" rx="1.5" /><rect x="14" y="14" width="7" height="7" rx="1.5" /></Svg>; }
function PenIcon() { return <Svg><path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" /></Svg>; }
function SparkleIcon() { return <Svg><path d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9Z" /></Svg>; }
function CalendarIcon() { return <Svg><rect x="3" y="4" width="18" height="18" rx="2" /><path d="M16 2v4M8 2v4M3 10h18" /></Svg>; }
function MegaphoneIcon() { return <Svg><path d="m3 11 16-5v12L3 14Z" /><path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" /></Svg>; }
function TargetIcon() { return <Svg><circle cx="12" cy="12" r="9" /><circle cx="12" cy="12" r="5" /><circle cx="12" cy="12" r="1" /></Svg>; }
function RocketIcon() { return <Svg><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09Z" /><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2Z" /><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /></Svg>; }
function ShareNodesIcon() { return <Svg><circle cx="18" cy="5" r="3" /><circle cx="6" cy="12" r="3" /><circle cx="18" cy="19" r="3" /><path d="m8.6 13.5 6.8 4M15.4 6.5 8.6 10.5" /></Svg>; }
function FileTextIcon() { return <Svg><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2Z" /><path d="M9 13h6M9 17h5" /></Svg>; }
function ImageIcon() { return <Svg><rect x="3" y="3" width="18" height="18" rx="2" /><circle cx="9" cy="9" r="1.8" /><path d="m21 15-4.5-4.5L6 21" /></Svg>; }
function LayoutIcon() { return <Svg><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18M9 21V9" /></Svg>; }
function CodeIcon() { return <Svg><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></Svg>; }
function MonitorIcon() { return <Svg><rect x="2" y="3" width="20" height="14" rx="2" /><path d="M8 21h8M12 17v4" /></Svg>; }
function DatabaseIcon() { return <Svg><ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5v14c0 1.7 4 3 9 3s9-1.3 9-3V5" /><path d="M3 12c0 1.7 4 3 9 3s9-1.3 9-3" /></Svg>; }

Story

  1. A job request, not an experiment

    This one didn't start in the lab. At work, a screen had an action menu that was already big, and the ticket asked for more options. Cramming them in would turn the menu into a wall of rows, so the real question became: how does a menu grow without sprawling?

  2. Looking for prior art

    I went looking for anyone who had taken menu depth seriously; almost nobody had. The closest was an iOS-style dropdown a design engineer built back in 2022: the feel was exactly right, but it was never open-sourced, so there was no code to learn from.

  3. The shadcn/ui & Base UI dead end

    First instinct: reach for the libraries. shadcn/ui's dropdown menu, then Base UI's menu. Nothing wrong with either; they handle a sub-menu or two beautifully. They just were never built for real depth: the fly-out pattern has no answer for a menu that keeps going, and forcing it there kept breaking down.

  4. Back to the sketchbook

    With nothing to fork, I started from a sketch instead of a library, inspired by that iOS-style menu: depth shouldn't fly out, it should stack in place. Then I built it level by level.

  5. First trial: breadcrumbs

    The first working version shipped as the Native Action Dropdown, with a breadcrumb showing where you are. It held up until you tried to go back: the breadcrumb told you where you were, but stepping back meant losing the menu you'd drilled through.

  6. Levels, rebuilt as a stack

    So the levels became panels. Each branch opens right under the row you clicked, the row's label becomes the new panel's header, and every parent stays behind, dimmed. Going back is one click on a panel you can already see: nothing gets lost.

  7. Then the animation came out

    The first cut animated everything; most of it got deleted. What stayed is the one movement that carries information: the row morphing into the panel header, so you can see where each level came from. A menu is a tool: it should feel fast before it feels fancy.

References