Table of contents
Introduction
As developers, we all encounter tricky problems that require creative solutions. One such problem I recently faced was creating a nested mobile navigation menu in React.
I needed to build a menu where clicking on a parent menu item expands a list of related submenus, which may contain additional nested submenus.
In this article, I’ll walk you through how I solved the problem step by step, using React and state management. If you’ve ever wanted to build a dynamic, multi-level mobile navigation menu for your website, this guide is for you.
Understanding the Problem
Nested menus allow users to navigate through hierarchical levels of content. Each subcategory can lead to more specific sections, creating a multi-level navigation structure.
For instance, in an e-commerce app, a menu might have a “Products” category, which opens into subcategories like “Men’s Clothing” or “Electronics”.
I wanted users to be able to:
Click on a parent menu item (like "Products") to see its child items.
Click on an item with a URL (like "Home") and go directly to that page.
Click on an item with more children and dive even deeper into the menu.
Go back up a level smoothly without reloading the page.
Planning the Solution
Data Structure
The foundation of the navigation system is a structured data array representing the menu hierarchy. The data contains both parent and child elements, with each element either having a direct URL or a set of child elements. Here’s the data structure I used:
const menuData = [
{ title: "Home", url: "/home" },
{
title: "Products",
children: [
{
title: "Men",
url: "/products/men",
},
{
title: "Women",
url: "/products/women",
},
{
title: "Electronics",
children: [
{
title: "Phones",
url: "/products/electronics/phones",
},
{
title: "Laptops",
url: "/products/electronics/laptops",
},
],
},
],
},
{
title: "Services",
children: [
{
title: "Same Day Delivery",
url: "/services/same-day-delivery",
},
{
title: "Customized Services",
url: "/services/customized-services",
},
],
},
{
title: "About",
url: "/about",
},
{
title: "Contact",
url: "/contact",
},
]
The menu structure is simple—it’s just an array of objects. Each object represents an item in the menu, and some of these items have children (submenus), while others have url (links to specific pages).
Implementing the Dynamic Nested Mobile Navigation Menu
Setting Up the Project
To begin, I created a new Nextjs project with Shadcn as the component library.
npx shadcn@latest init
I also installed Framer Motion to add subtle animations when menu items load, giving a nice, smooth experience when navigating between levels.
npm i framer-motion
Catch-all Page
Each menu item may have multiple sub-levels for our navigation menu, requiring a flexible route handler.
By using Next.js Optional Catch-all Segments, we can define a single route in Next.js to handle multiple layers of navigation without creating a separate file for each level.
The [[...route]]/page.tsx
file would catch all routes and display the current route as a heading on the page.
import Link from "next/link";
import { Menu } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetTrigger, SheetContent } from "@/components/ui/sheet";
import MobileNav from "./_components/MobileNav";
export default function CurrPage({ params }: { params: { route: string[] } }) {
const showParams = (route: string[]) => {
if (!route) return "Index Page";
return `${route.join(", ")} Page`;
};
return (
<div className="flex flex-col h-screen bg-white text-slate-800">
<header className="flex items-center justify-between px-4 py-3 border-b">
<Link href="#" className="text-lg font-semibold" prefetch={false}>
Mobile Navigation
</Link>
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full">
<Menu className="w-6 h-6" />
<span className="sr-only">Toggle menu</span>
</Button>
</SheetTrigger>
<SheetContent
side="right"
className="w-full max-w-xs bg-white p-4 pt-8"
>
<MobileNav />
</SheetContent>
</Sheet>
</header>
<section className="my-4 text-center text-2xl font-bold capitalize px-4">
{showParams(params.route)}
</section>
</div>
);
}
The CurrPage component is a Next.js page that utilizes a Shadcn Sheet component for a mobile navigation drawer and dynamically renders the current route on the page based on route parameters.
The showParams
function checks whether a route is provided, and based on the dynamic segments, it generates a string to display which page is currently being viewed. If there’s no route, it shows "Index Page" otherwise it concatenates the route segments.
Navigation Component
Next, I built the core component responsible for rendering the current menu and handling navigation between parent and child menus. Here's how I structured the component:
"use client";
import { useState } from "react";
import Link from "next/link";
import { Link as LinkIcon } from "lucide-react";
import { motion } from "framer-motion";
import { navigation, NavigationType } from "@/lib/constants";
const MobileNav = () => {
// `currList` holds the current list of navigation items, initially set to the main navigation.
const [currList, setCurrList] = useState(navigation);
// `navStack` holds the navigation history as an array, used for back navigation.
const [navStack, setNavStack] = useState<NavigationType[]>([]);
// `titleStack` holds the title history as an array, used for showing the title.
const [titleStack, setTitleStack] = useState<string[]>([]);
// Handle when an item in the navigation list is clicked.
const handleItemClick = (item: NavigationType[0]) => {
if (item.children) {
setNavStack([...navStack, currList]);
setTitleStack([...titleStack, item.title]);
setCurrList(item.children);
}
};
// Handle the "Back" button click to return to the previous navigation level.
const handleBackClick = () => {
if (navStack.length > 0) {
const previousItems = navStack[navStack.length - 1];
setCurrList(previousItems);
setNavStack(navStack.slice(0, -1));
setTitleStack(titleStack.slice(0, -1));
}
};
return (
<div className="w-full max-w-md mx-auto bg-white overflow-hidden mt-2">
{/* Show the back button if there is navigation history */}
{navStack.length > 0 && (
<div className="flex items-center p-4 bg-gray-100 border-b text-slate-800">
<button onClick={handleBackClick} className="mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.828 11H20v2H7.828l5.364 5.364l-1.414 1.414L4 12l7.778-7.778l1.414 1.414z"
/>
</svg>
</button>
{/* Show the current navigation title */}
<span className="font-semibold text-slate-800">{titleStack.slice(-1)}</span>
</div>
)}
{/* Display the list of current navigation items */}
<ul className="divide-y divide-gray-200">
{currList.map((item) => (
<motion.li
key={item.title}
className="p-4"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ease: "easeInOut", duration: 0.75 }}
>
{item.url ? (
<Link
key={item.title}
href={item.url}
className="w-full text-left flex justify-between items-center hover:text-slate-500 text-slate-800 transition-colors"
prefetch={false}
>
<span>{item.title}</span>
<LinkIcon className="h-5 w-5" />
</Link>
) : (
<button
onClick={() => handleItemClick(item)}
className="w-full text-left flex justify-between items-center hover:text-slate-500 text-slate-800"
>
<span>{item.title}</span>
{/* Show the right arrow icon if the item has children */}
{item.children && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</button>
)}
</motion.li>
))}
</ul>
</div>
);
};
export default MobileNav;
The MobileNav
component contains the navigation functionality. It handles nested menus using a currList
state to represent the current items and a navStack
state to store the previous levels of navigation.
Walkthrough of Key Features
State Initialization
const [currList, setCurrList] = useState(navigation);
const [navStack, setNavStack] = useState<NavigationType[]>([]);
const [titleStack, setTitleStack] = useState<string[]>([]);
currList
holds the current menu items displayed to the user, initialized to the top-level navigation.navStack
is an array that keeps track of the previously visited menus, allowing users to go back to the previous navigation level.titleStack
keeps track of the titles of the currently displayed menu, used to show where the user is within the navigation.
Item Click Handling
const handleItemClick = (item: NavigationType[0]) => {
if (item.children) {
setNavStack([...navStack, currList]);
setTitleStack([...titleStack, item.title]);
setCurrList(item.children);
}
};
This function gets triggered when a navigation item is clicked. If the clicked item has child elements (children), the current list is added to the navStack
array, and the currList
state is updated to display the children.
Back Navigation
const handleBackClick = () => {
if (navStack.length > 0) {
const previousItems = navStack[navStack.length - 1];
setCurrList(previousItems);
setNavStack(navStack.slice(0, -1));
setTitleStack(titleStack.slice(0, -1));
}
};
The handleBackClick
function restores the previous navigation items from the stack and removes the latest entry in navStack
and titleStack
.
Rendering the Navigation
- Back Button and Sub-menu title:
{navStack.length > 0 && (
<div className="flex items-center p-4 bg-gray-100 border-b text-slate-800">
<button onClick={handleBackClick} className="mr-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M7.828 11H20v2H7.828l5.364 5.364l-1.414 1.414L4 12l7.778-7.778l1.414 1.414z"
/>
</svg>
</button>
{/* Show the current navigation title */}
<span className="font-semibold text-slate-800">{titleStack.slice(-1)}</span>
</div>
)}
Only shown when navStack
is not empty.
- Current Menu List:
<ul className="divide-y divide-gray-200">
{currList.map((item) => (
<motion.li
key={item.title}
className="p-4"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ ease: "easeInOut", duration: 0.75 }}
>
{item.url ? (
<Link
key={item.title}
href={item.url}
className="w-full text-left flex justify-between items-center hover:text-slate-500 text-slate-800 transition-colors"
prefetch={false}
>
<span>{item.title}</span>
<LinkIcon className="h-5 w-5" />
</Link>
) : (
<button
onClick={() => handleItemClick(item)}
className="w-full text-left flex justify-between items-center hover:text-slate-500 text-slate-800"
>
<span>{item.title}</span>
{/* Show the right arrow icon if the item has children */}
{item.children && (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
)}
</button>
)}
</motion.li>
))}
</ul>
Dynamically displays the current level of the navigation, with either a link or a button depending on whether the item has a url or children.
Conclusion
Building a dynamic nested mobile navigation menu in React required a careful balance of state management and user experience design.
Using React's useState
for dynamic rendering, I was able to create an intuitive navigation system that can handle complex menu structures.
If you’re building a web app with multi-level menus, I hope this guide has given you the tools and inspiration to tackle the challenge.
I have attached a link to the code on github.