1. Web Design
  2. JavaScript

Build a Multilevel Animated Mobile Menu With JavaScript

Scroll to top
Read Time: 12 min

Today, we’ll discuss how to create a three-level deep sliding mobile menu without using any library, just with pure JavaScript. By default, the first one will appear as soon as we open the menu. The other two will come into view with a slide animation upon request.

One of my first tutorials here in Tuts+ back in 2015 covered the creation of an off-canvas sliding menu with jQuery's version of mmenu.js. If you check the demo project, you’ll notice that the menu includes multiple levels.

Ready for another challenge?!

Our Multilevel Menu

Check the menu we’ll be creating in the pen below:

1. Define the HTML Markup

The markup for our mobile menu will consist of the following elements:

  • A header with a nav inside it. 
  • A main where the main content of our page will live.

Inside the nav, we’ll put two divs. The first one will have the header-bar class, while the second will have the menu-wrapper one.

The .header-bar will consist of three elements:

  • The toggle menu
  • The company logo
  • The company's Twitter account

Inside the .menu-wrapper, we’ll put three divs with the class of list-wrapper. We’ll call them panels for simplicity. Things to note:

  • In the first panel, we’ll specify the menu structure with parent and child menu items. To do this, we’ll use a typical markup with nested unordered lists.
  • The second and third panels will contain a back button and an empty div with the class of sub-menu-wrapper. More about their job:
    • The back button will help us go up one level. That said, from level three to level two and from level two to level one.
    • The .sub-menu-wrapper of the second panel will contain the second-level links. In the same way, the .sub-menu-wrapper of the third panel will hold the third-level links. We’ll insert these links dynamically through JavaScript.

With all the above in mind, the following markup comes up:

1
<header class="page-header">
2
  <nav>
3
    <div class="header-bar">
4
      <button class="toggle-menu" type="button">
5
        MENU
6
      </button>
7
      <a href="" class="brand">BRAND</a>
8
      <a href="" class="social" target="_blank" title="">
9
        <svg xmlns="https://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
10
          <path d="M24 4.557c-.883.392-1.832.656-2.828.775 1.017-.609 1.798-1.574 2.165-2.724-.951.564-2.005.974-3.127 1.195-.897-.957-2.178-1.555-3.594-1.555-3.179 0-5.515 2.966-4.797 6.045-4.091-.205-7.719-2.165-10.148-5.144-1.29 2.213-.669 5.108 1.523 6.574-.806-.026-1.566-.247-2.229-.616-.054 2.281 1.581 4.415 3.949 4.89-.693.188-1.452.232-2.224.084.626 1.956 2.444 3.379 4.6 3.419-2.07 1.623-4.678 2.348-7.29 2.04 2.179 1.397 4.768 2.212 7.548 2.212 9.142 0 14.307-7.721 13.995-14.646.962-.695 1.797-1.562 2.457-2.549z" />
11
        </svg>
12
      </a>
13
    </div>
14
    <div class="menu-wrapper">
15
      <div class="list-wrapper">
16
        <ul class="menu level-1">
17
          <li>
18
            <a href="" class="nested">Categories </a>
19
            <ul class="sub-menu level-2">
20
              <li>
21
                <a href="" class="nested">Living Room </a>
22
                <ul class="sub-menu level-3">...</ul>
23
              </li>
24
              <li>
25
                <a href="">Dining Room</a>
26
              </li>
27
              ...
28
            </ul>
29
          </li>
30
          <li>
31
            <a href="" class="nested">Featured Products</a>
32
            <ul class="sub-menu level-2">...</ul>
33
          </li>
34
          ...
35
        </ul>
36
      </div>
37
      <div class="list-wrapper">
38
        <button type="button" class="back-one-level">
39
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
40
            <path d="M16.67 0l2.83 2.829-9.339 9.175 9.339 9.167-2.83 2.829-12.17-11.996z" />
41
          </svg>
42
          <span>Back</span>
43
        </button>
44
        <div class="sub-menu-wrapper"></div>
45
      </div>
46
      <div class="list-wrapper">
47
        <button type="button" class="back-one-level">
48
          <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24">
49
            <path d="M16.67 0l2.83 2.829-9.339 9.175 9.339 9.167-2.83 2.829-12.17-11.996z" />
50
          </svg>
51
          <span>Back</span>
52
        </button>
53
        <div class="sub-menu-wrapper"></div>
54
      </div>
55
    </div>
56
  </nav>
57
</header>
58
59
<main class="page-main">...</main>

2. Specify the Main Styles

Let’s now concentrate on the header styles for our mobile menu.

For the sake of simplicity, I won’t walk through all the styles, but feel free to look at them by clicking on the CSS tab of the demo project.

Some things to note:

  • The header will be a fixed positioned element and have a maximum width of 600px.
  • The .header-bar will have a fixed height of 60px.
  • The .menu-wrapper will be absolutely positioned and sit underneath the .header-bar. Furthermore, it will have a height equal to the viewport height minus the .header-bar’s height. Lastly, it will initially be hidden.
The header layoutThe header layoutThe header layout

The associated styles:

1
/*CUSTOM VARIABLES HERE*/
2
3
.page-header {
4
  position: fixed;
5
  top: 0;
6
  left: 50%;
7
  transform: translateX(-50%);
8
  width: 100%;
9
  max-width: 600px;
10
  margin: 0 auto;
11
  color: var(--white);
12
}
13
14
.page-header .header-bar {
15
  display: flex;
16
  justify-content: space-between;
17
  align-items: center;
18
  height: 60px;
19
  padding: 0 20px;
20
  background: var(--header-bar-bg);
21
}
22
23
.page-header .menu-wrapper {
24
  display: none;
25
  position: absolute;
26
  top: 60px;
27
  left: 0;
28
  width: 100%;
29
  height: calc(100vh - 60px);
30
  overflow: hidden;
31
}

Let’s continue with the panel styles.

  • All of them will receive their parent's height and have overflow-y: auto. This property ensures that a scrollbar will appear in case there are a lot of menu links inside it.
  • Especially the second and third panels will be absolutely positioned and out of the screen by default.

The associated styles:

1
/*CUSTOM VARIABLES HERE*/
2
3
.page-header .list-wrapper {
4
  height: 100%;
5
  padding: 30px 20px;
6
  overflow-y: auto;
7
  background: var(--menu-bg);
8
}
9
10
.page-header .list-wrapper:nth-child(2),
11
.page-header .list-wrapper:nth-child(3) {
12
  position: absolute;
13
  top: 0;
14
  left: 0;
15
  right: 0;
16
  transform: translateX(100%);
17
  backface-visibility: hidden;
18
  transition: transform 0.5s;
19
}

Next, we'll hide all nested menus from the first and third panels:

1
.page-header .list-wrapper:nth-child(1) > ul > li > .sub-menu,
2
.page-header .list-wrapper:nth-child(2) .level-3 {
3
  display: none;
4
}

Moving on, we’ll set some styles for the mobile menu links, specifically:

  • Links that open a nested menu will be underlined.
  • To indicate that a link is active or hovered, we'll give it a different color and a character.
The link appearance when is active or hovered

The associated styles:

1
/*CUSTOM VARIABLES HERE*/
2
3
.page-header .menu-wrapper a {
4
  display: inline-block;
5
  position: relative;
6
  padding: 5px 0;
7
}
8
9
.page-header .menu-wrapper a.nested {
10
  text-decoration: underline;
11
}
12
13
.page-header .menu-wrapper a:hover,
14
.page-header .menu-wrapper a.is-active {
15
  color: var(--orange);
16
}
17
18
.page-header .menu-wrapper a:hover::before,
19
.page-header .menu-wrapper a.is-active::before {
20
  content: "✦";
21
  position: absolute;
22
  top: 50%;
23
  right: -20px;
24
  transform: translateY(-50%);
25
  color: var(--orange);
26
}
27
28
.page-header .back-one-level {
29
  display: flex;
30
  align-items: center;
31
  margin-bottom: 40px;
32
}

Finally, we’ll specify some straightforward styles for the back button.

The back button

Here they are:

1
/*CUSTOM VARIABLES HERE*/
2
3
.page-header .back-one-level {
4
  display: flex;
5
  align-items: center;
6
  margin-bottom: 40px;
7
}
8
9
.page-header .back-one-level svg {
10
  fill: var(--white);
11
  margin-right: 10px;
12
}

3. Add the JavaScript

After setting up the styles, it's time to discuss the actions needed for revealing the nested menu levels with slide animation. 

Toggle Menu

Here’s an animated GIF that illustrates the menu’s toggle state:

The menu statesThe menu statesThe menu states

Each time we click on the toggle button, we’ll perform the following actions:

  1. Toggle the menu’s visibility via the is-visible class. If it’s hidden, it will appear and vice versa.
  2. Check if the menu is closed. If this condition is fulfilled, we’ll do the following:
    1. Remove the is-visible class from the second and third panels if they have it.
    2. Remove the is-active class from the active menu links if there are such elements.

Here’s the required JavaScript code:

1
const pageHeader = document.querySelector(".page-header");
2
const toggleMenu = pageHeader.querySelector(".toggle-menu");
3
const menuWrapper = pageHeader.querySelector(".menu-wrapper");
4
const listWrapper2 = pageHeader.querySelector(".list-wrapper:nth-child(2)");
5
const listWrapper3 = pageHeader.querySelector(".list-wrapper:nth-child(3)");
6
const isVisibleClass = "is-visible";
7
const isActiveClass = "is-active";
8
9
toggleMenu.addEventListener("click", function () {
10
  // 1

11
  menuWrapper.classList.toggle(isVisibleClass);
12
  // 2

13
  if (!this.classList.contains(isVisibleClass)) {
14
    // 1

15
    listWrapper2.classList.remove(isVisibleClass);
16
    listWrapper3.classList.remove(isVisibleClass);
17
    // 2

18
    const menuLinks = menuWrapper.querySelectorAll(".is-active");
19
    for (const menuLink of menuLinks) {
20
      menuLink.classList.remove(isActiveClass);
21
    }
22
  }
23
});

And the relevant styles:

1
.page-header .menu-wrapper.is-visible {
2
  display: block;
3
}
4
5
.page-header .list-wrapper:nth-child(2),
6
.page-header .list-wrapper:nth-child(3) {
7
  transition: transform 0.5s;
8
}
9
10
.page-header .list-wrapper:nth-child(2).is-visible,
11
.page-header .list-wrapper:nth-child(3).is-visible {
12
  transform: none;
13
}

Open Level Two

Here’s an animated GIF that illustrates how the second-level menus will appear:

The second level of menuThe second level of menuThe second level of menu

Each time we click on a menu link of the first level (the visible ones), we’ll check to see if it has a nested menu as a sibling. If this condition is fulfilled, we’ll perform the following actions:

  1. Prevent its default action.
  2. Assign the is-active class to it.
  3. Create a deep copy of its sibling.
  4. Append this newly created node into the .sub-menu-wrapper of the second panel.
  5. Reveal the second panel with slide animation.

Here’s the required JavaScript code:

1
...
2
3
for (const level1Link of level1Links) {
4
  level1Link.addEventListener("click", function (e) {
5
    const siblingList = level1Link.nextElementSibling;
6
    if (siblingList) {
7
      // 1

8
      e.preventDefault();
9
      // 2

10
      this.classList.add(isActiveClass);
11
      // 3

12
      const cloneSiblingList = siblingList.cloneNode(true);
13
      // 4

14
      subMenuWrapper2.innerHTML = "";
15
      subMenuWrapper2.append(cloneSiblingList);
16
      // 5

17
      listWrapper2.classList.add(isVisibleClass);
18
    }
19
  });
20
}

And the associated styles for the animation:

1
.page-header .list-wrapper:nth-child(2) {
2
  transition: transform 0.5s;
3
}
4
5
.page-header .list-wrapper:nth-child(2).is-visible {
6
  transform: none;
7
}

Open Level Three

Here’s an animated GIF that illustrates how the third-level menus will appear:

The third level of menuThe third level of menuThe third level of menu

Remember that by default, there isn’t any content inside the .sub-menu-wrapper of the second panel. In the previous section, we described how it receives content by performing a deep copy. 

Here’s an example of the generated markup:

The contents of the second level when becomes visibleThe contents of the second level when becomes visibleThe contents of the second level when becomes visible

Next, we have to perform some actions as soon as someone clicks on a menu link of the second level. However, the tricky thing is that these links are dynamically generated and aren’t part of the initial DOM. That said, the click event won’t work for these elements :(

Happily, the solution is simple enough. Thanks to the event delegation, we’ll attach the click event to the parent panel which is part of the DOM. Then, via the target property of that event, we’ll check the elements on which the event occurred to ensure that these are links with a sub-menu sibling.

Assuming these are the target elements, for each one of them, we’ll do the following things (similar to the previous section):

  1. Prevent its default action.
  2. Assign the is-active class to it.
  3. Create a (deep) copy of its sibling. Note that there won’t be any change if we do a deep copy or not, as our menu contains three levels. 
  4. Append this newly created node into the .sub-menu-wrapper of the third panel.
  5. Reveal the third panel with slide animation.

Here’s the required JavaScript code:

1
...
2
3
listWrapper2.addEventListener("click", function (e) {
4
  const target = e.target;
5
  if (target.tagName.toLowerCase() === "a" && target.nextElementSibling) {
6
    const siblingList = target.nextElementSibling;
7
    // 1

8
    e.preventDefault();
9
    // 2

10
    target.classList.add(isActiveClass);
11
    // 3

12
    const cloneSiblingList = siblingList.cloneNode(true);
13
    // 4

14
    subMenuWrapper3.innerHTML = "";
15
    subMenuWrapper3.append(cloneSiblingList);
16
    // 5

17
    listWrapper3.classList.add(isVisibleClass);
18
  }
19
});

And the relevant styles for the animation:

1
.page-header .list-wrapper:nth-child(3) {
2
  transition: transform 0.5s;
3
}
4
5
.page-header .list-wrapper:nth-child(3).is-visible {
6
  transform: none;
7
}

Go Back One Level

Here’s an animated GIF that illustrates how the back buttons will work:

Go back one levelGo back one levelGo back one level

Each time we click on a back button, we’ll perform the following actions:

  1. Find the parent panel of the target back button and remove its is-visible class.
  2. Remove the is-active class from the active link of the previous sibling of the parent panel. 

Here’s the required JavaScript code:

1
...
2
3
for (const backOneLevelBtn of backOneLevelBtns) {
4
  backOneLevelBtn.addEventListener("click", function () {
5
    // 1

6
    const parent = this.closest(".list-wrapper");
7
    parent.classList.remove(isVisibleClass);
8
    // 2

9
    parent.previousElementSibling
10
      .querySelector(".is-active")
11
      .classList.remove(isActiveClass);
12
  });
13
}

And again, there are some accompanying CSS styles for this action.

EXTRA: Update Back Text

This tutorial has been updated with an extra step, requested by one of our readers (great idea!)

Now that our mobile menu works the way we want, we can build different variations of it. For instance, let’s assume that we want to replace the hardcoded Back text with text from the menu link that is being clicked.

A SCREENSHOT SHOWING THE DYNAMIC MENU ITEM ONCE IT’S BEEN CLICKEDA SCREENSHOT SHOWING THE DYNAMIC MENU ITEM ONCE IT’S BEEN CLICKEDA SCREENSHOT SHOWING THE DYNAMIC MENU ITEM ONCE IT’S BEEN CLICKED

For consistency, we’ll keep our HTML as it is and we’ll only add a bit of JavaScript (marked with the EXTRA comment) like this:

1
...
2
// EXTRA

3
const backLabel2 = listWrapper2.querySelector(".back-one-level span");
4
const backLabel3 = listWrapper3.querySelector(".back-one-level span");
5
6
toggleMenu.addEventListener("click", function () {
7
  menuWrapper.classList.toggle(isVisibleClass);
8
  if (!this.classList.contains(isVisibleClass)) {
9
    // EXTRA OPTIONALLY

10
    backLabel2.textContent = "";
11
    backLabel3.textContent = "";
12
  }
13
});
14
15
for (const level1Link of level1Links) {
16
  level1Link.addEventListener("click", function (e) {
17
    const siblingList = level1Link.nextElementSibling;
18
    if (siblingList) {
19
      // EXTRA

20
      backLabel2.textContent = level1Link.textContent;
21
    }
22
  });
23
}
24
25
listWrapper2.addEventListener("click", function (e) {
26
  const target = e.target;
27
  if (target.tagName.toLowerCase() === "a" && target.nextElementSibling) {
28
    ...
29
    // EXTRA

30
    backLabel3.textContent = target.textContent;
31
  }
32
});
33
34
...

Conclusion

In this tutorial, we created a multi-level mobile menu by taking advantage of some common CSS styles and JavaScript DOM’s API. Most importantly, our initial markup contains simple nested lists for menu creation. That means we can transpile it into a dynamic one and take advantage of a CMS’s features like WordPress with just a few modifications.

I’m sure there will be other solutions for building such menus, perhaps more effective or accessible than this one. Our approach takes advantage heavily of the cloneNode() method that creates duplicate nodes, so it should be used with caution, especially if our sub-menus contain IDs.

Here’s a reminder of our today’s exercise (this is the dynamic back text example):

If you liked this exercise and it saved you some time for building such a solution from scratch, don’t forget to give it some ❤️. Plus, if you’d like to see another version or an extension of this menu, let us know!

As always, thanks a lot for reading!

Advertisement
Did you find this post useful?
Want a weekly email summary?
Subscribe below and we’ll send you a weekly email summary of all new Web Design tutorials. Never miss out on learning about the next big thing.
Looking for something to help kick start your next project?
Envato Market has a range of items for sale to help get you started.