Toggle Navigational Menus and Submenus Using Pure JavaScript

We had an interesting challenge today, where we needed to create a collapsible, responsive menu and sub-menu, using pure JavaScript.

Normally this functionality would be part of a frontend framework, but the client’s site doesn’t use any so, rather than using one solely for this functionality, we decided a clean and efficient way would be to write it in pure JavaScript.

The Components

Here are the fruits of the couple of hours this little beaut took to code up. The HMTL is explicitly for a responsive nav menu, whereas the CSS and JavaScript have been written to support toggling any elements.

HTML

The HTML’s for a menu and buttons to toggler its and its submenu’s visibilty. This is pretty self-explanitory, so I haven’t bothered littering it with unnecesary comments. All we have is a header element that contains some togglers and a nav element with items and a submenu.

<header role="banner">
  <div class="d-md-none">
    <button class="btn-toggler btn-menu" data-toggle="visiblilty" data-target="#nav-primary">☰</button>
    <button class="btn-toggler btn-close" data-toggle="visiblilty" data-target="#nav-primary">×</button>
  </div>
  <nav id="nav-primary" role="navigation" class="nav-menu hide">
    <ul>
      <li><a href="#">home</a></li>
      <li><a href="#">about</a></li>
      <li><button class='btn-link btn-submenu' data-toggle="visiblilty" data-target="#nav-submenu">sub-menu <span>▼</span></button>
        <ul id="nav-submenu" role="navigation" class="-hide">
          <li><a href="#">sub 1</a></li>
          <li><a href="#">sub 2</a></li>
          <li><a href="#">sub 3</a></li>
        </ul>
      </li>
      <li><a href="#">contact</a></li>
    </ul>
  </nav>
</header>

CSS

The CSS is ‘mobile-first’, meaning the default styles are intended for smaller devices, with larger devices catered for using @media queries. The default layout of the nav is stacked, but hidden and toggled with a toggler button. As is the submenu. For larger devices the nav menu is always displayed in a horizontal layout.

/** Base */
body {
  font-size: 16px;
  font-family: arial, sans-serif;
}

/** Buttons */
.btn-toggler,
.btn-link {
  outline-width: 0 !important;
  background-color: transparent;
  font-family: arial, sans-serif;
}

.btn-link {
  font-size: 16px;
  border-width: 0;
  padding: 0;
  text-decoration: underline;
  cursor: pointer;
}

.btn-toggler {
  font-size: 1.5rem;
  line-height: 1;
  text-transform: uppercase;
  padding: 0.25rem 0.5rem;
  min-width: 50px;
  color: tomato;
  border: 2px solid currentColor;
}

.btn-toggler:not(:last-of-type) {
  margin-right: 0.5rem;
}

.btn-menu[aria-expanded='true'],
.btn-close[aria-expanded='false'] {
  display: none;
}

.btn-submenu span {
  font-size: 0.5em;
  line-height: 1.75;
}

/** Nav menus */
.nav-menu ul {
  display: flex;
  flex-direction: column;
  list-style: none;
  padding: 0;
}

.nav-menu a, 
.nav-menu .btn-link {
  display: flex;
  box-sizing: border-box;
  padding: 0.5rem;
  width: 100%;
  text-decoration: none;
  text-transform: uppercase;
  color: tomato;
}

.nav-menu a:hover, 
.nav-menu .btn-link:hover {
  background-color: rgba(0, 0, 0, 0.1);
}

/** Hide element */
.hide {
  display: none !important;
}

/** Media breakpoints */
@media (min-width: 768px) {
  .d-md-none {
    display: none;
  }

  /** Show nav menu */
  .nav-menu {
    display: flex !important;
  }

  /** Display menu items horizonatally */
  .nav-menu > ul {
    flex-direction: row;
  }
}

JavaScript

Toggling the visisbilty of elements is done using JavaScript, by toggling state classes and aria-expanded attributes.

var toggler = document.querySelectorAll("[data-toggle='visiblilty']"),
    targetShown,
    target,
    newTarget = true;

// loop through all togglers
for (var i=0; i<=toggler.length; i++) {
  // for efficiency, only process a toggler's target if it's different from the previous target
  if (document.querySelector(toggler[i].dataset.target) === target) {
    newTarget = false;
  }

  // set the target element and its visibility class
  if (newTarget) {
    // get the target element
    target = document.querySelector(toggler[i].dataset.target);

    // ascertain the target's current visibilty
    targetShown = !target.classList.contains('hide');
  }

  // set the toggler's aria-expanded state based on the target's visibilty class
  toggler[i].setAttribute('aria-expanded', targetShown);

  // listen for toggler click events
  toggler[i].onclick = function() {
    // get the target element
    target = document.querySelector(this.dataset.target);

    // toggle the target's visibilty class and toggler's aria-expanded state
    if (target.classList.contains('hide')) {
      target.classList.replace("hide", "show");
      targetShown = true;
    } else {
      // precautionarily set the 'show' class (incase it doesn't exist)
      target.classList.add("show");
      target.classList.replace("show", "hide");
      targetShown = false;
    }

    // update the aria-expanded state for this target's togglers
    for (var j = 0; j <=toggler.length; j++) {
      if (toggler[j].dataset.target === this.dataset.target) {
        toggler[j].setAttribute('aria-expanded', targetShown);
      }
    }
  };
}

Conclusion

Using frontend frameworks to handle the nuts-n-bolts of HTML, CSS and JS normally address most common layout and functional aspects of a website, but there are often times when specific requirements need to be written to achieve the desired outcome. This example shows that it’s often not too difficult or time-consuming to implement them (if you know what you’re doing).

We love these challenges, so if you come across any that you’d like a hand with please get in touch and we’ll gladly take on the challenge.