DEV Community

Cover image for Building a Dynamic Nested Mobile Navigation Menu with React
Anderson Osayerie
Anderson Osayerie

Posted on

Building a Dynamic Nested Mobile Navigation Menu with React

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.

Image showing nested menu

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",
  },
]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Catch-all Page

For our navigation menu, each menu item may have multiple sub-levels, 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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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 there is a route 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;

Enter fullscreen mode Exit fullscreen mode

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[]>([]);
Enter fullscreen mode Exit fullscreen mode
  • 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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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));
  }
};
Enter fullscreen mode Exit fullscreen mode

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>
      )}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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.

Image description

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.

Top comments (0)