Codementor Events

Flexbox Priority Navigation

Published Nov 03, 2020
Flexbox Priority Navigation

The Problem

What is your least favorite part of building a website? The design? The animations? The form handling?

For me, it is the menu. Menus are the bane of my existence. Every time I'm creating a new site, the site needs a menu. Of course, there are plenty of menus out there I could use. The problem is, none of them works exactly the way I want it to. So I take an existing menu and modify it to create my own. Once I have it working, I reuse it . . . until I find an irreparable flaw or a new, better type of menu comes along. Then it's back to the drawing board.

The new, better type of menu I last attempted to tackle? Priority navigation.

Priority navigation is a type of horizontal menu which shows as many links as space allows, hiding the rest under a dynamically created (and updated) "more" link. It improves user experience by:

  • Keeping important items visible for as long as possible
  • Reducing the need for menu buttons, which, especially when displayed as icons without labels, can be confusing to users

I was really excited about the improved responsiveness and usability of this menu type. However, there was one part I was not excited about: none of the examples I found used Flexbox.

In my experience, Flexbox is the fastest, most widely supported modern method for spacing and aligning items. That is why I use it on menus all the time. In just a few lines of code, I can have evenly spaced, vertically centered menu items. This also makes it easier to reuse menu code between sites—since the menu is built to be flexible, less needs to be changed.

So when I couldn't find anyone building a priority navigation with my beloved Flexbox, I was determined to remedy the problem. And now I'm going to share with you how I did it.

The Solution

HTML

First, some test HTML to work with. When I originally created this menu, I made it to use with a WordPress theme. Since WordPress is a popular platform, for this example I'll stick with WordPress-compatible code.

Here is an HTML header I pulled from a WordPress site, then modified to remove identifiers:

<header id="header" role="banner">
    <div id="mainNavigation" class="group">
      <div class="max-width">
        <section id="branding">
          <p class="screen-reader-text">Navigation Test</p>
          <a href="#skipMenu" class="screen-reader-text">Skip to Content</a>
          <div id="siteIdentity">
            <a href="#" rel="home">
              [Logo Here]
            </a>
          </div>
        </section>
        <nav id="menu" role="navigation">
          <div class="menu-main-menu-container">
            <ul id="menu-main-menu" class="menu">
              <li id="menu-item-28" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-home current-menu-item page_item page-item-7 current_page_item menu-item-28">
                <a href="#" aria-current="page">Home</a>
              </li>
              <li id="menu-item-27" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-27">
                <a href="#">About Us</a>
              </li>
              <li id="menu-item-26" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-has-children menu-item-26">
                <a href="#">Services</a>
                <ul class="sub-menu">
                  <li id="menu-item-25" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-25">
                    <a href="#">Service 1</a>
                  </li>
                  <li id="menu-item-24" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-24">
                    <a href="#">Service 2</a>
                  </li>
                </ul>
              </li>
              <li id="menu-item-23" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-has-children menu-item-396">
                <a href="#">Products</a>
                <ul class="sub-menu">
                  <li id="menu-item-25" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-397">
                    <a href="#">Product 1</a>
                  </li>
                  <li id="menu-item-24" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-398">
                    <a href="#">Product 2</a>
                  </li>
                  <li id="menu-item-24" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-399">
                    <a href="#">Product 3</a>
                  </li>
                  <li id="menu-item-24" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-400">
                    <a href="#">Product 4</a>
                  </li>
                  <li id="menu-item-24" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-401">
                    <a href="#">Product 5</a>
                  </li>
                </ul>
              </li>
              <li id="menu-item-22" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-22">
                <a href="#">Portfolio</a>
              </li>
              <li id="menu-item-395" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-395">
                <a href="#">Resources</a>
              </li>
              <li id="menu-item-23" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-23">
                <a href="#">Careers</a>
              </li>
              <li id="menu-item-32" class="menu-item menu-item-type-post_type menu-item-object-page menu-item-32">
                <a href="#">Contact Us</a>
              </li>
            </ul>
          </div>
        </nav>
        <a id="skipMenu" class="screen-reader-text"></a>
      </div>
    </div>
  </header>

Here is a CodePen of the same:
@Flexbox Priority Navigation - Step 1: HTML
CodePen_Screenshot_1.png

CSS

Logo & Menu Spacing

Next, of course, the header needs to be styled. The logo and the menu each need their own space defined. Most logos will have an ideal display width on a site, so I will set my #branding space to 15rem (240px based on a browser font size of 16px):

#branding {
  width: 15rem;
  float: left;
}

Since I added a margin to my "logo" inside the #branding div, there doesn't need to be any space between the #branding and nav containers.

#siteIdentity {
  margin-right: 2.5rem;
}

Therefore, the nav width is simply 100% minus the width of #branding:

nav {
  width: calc(100% - 15rem);
  float: right;
}

Adding Flexbox to the Menu

Time to add Flexbox to the menu! Since menu items will hide when they overflow, wrapping is neither wanted nor needed. (Note: Browser prefixes are to support older browsers.)

#menu-main-menu {
  -ms-flex-pack: end;
  flex-pack: end;
  -webkit-justify-content: flex-end;
  -moz-justify-content: flex-end;
  -ms-justify-content: flex-end;
  justify-content: flex-end;
}

#menu-main-menu > li {
  position: relative;
  text-align: center;
  -webkit-box-flex: 0 0 auto;
  -moz-box-flex: 0 0 auto;
  width: 0 0 auto;
  -webkit-flex: 0 0 auto;
  -ms-flex: 0 0 auto;
  flex: 0 0 auto;
  padding: 1.75rem 0;
}

#menu-main-menu > li:not(.hidden-links),
.menu-main-menu-container button {
  white-space: nowrap;
}

Hiding Sub-Menus

Finally, the sub-menus need to be hidden:

ul#menu-main-menu li > ul {
  display: none;
  position: absolute;
  top: 100%;
  background: #fff;
  padding: 0;
}

Result

Here's a CodePen showing what it looks like with all my CSS, including the pieces highlighted above:
@Flexbox Priority Navigation - Step 2: CSS
CodePen_Screenshot_2.png

jQuery

Obviously, there's still work to do. The CSS makes it look better, but the menu items run right over the logo when given the chance. What do we need to add in order to make the menu items disappear under a "more" menu? JavaScript. Priority navigation doesn't work without JavaScript. In this case, since I was working with WordPress and jQuery was already included, I chose to use the jQuery library for easier access to the DOM. (Note: the use of "jQuery" rather than the dollar-sign shorthand "$" is due to WordPress not allowing the dollar sign by default.)

On Page Load

The most common way to hide menu items—and the method I followed—is to calculate widths. I thought it best to store the width of each menu item on page load. I also add my "more" sub-menu dynamically on page load—again because, at the time, I was working with a WordPress-generated menu structure. (If one had the ability to add the item in the HTML, that would be more efficient.) The rest of the jQuery(document).ready function is simply to handle dropdowns on click.

/ global variables
var navItems = [];
var navItemWidth = [];
var navItemVisible = [];
var moreWidth = 0;
var winWidth = 0;

jQuery(document).ready(function () {
  winWidth = jQuery(window).width();
  
  navItems = jQuery('#menu-main-menu > li');
  
  // get width of each item, and list each as visible
  navItems.each(function () {
    var itemWidth = jQuery(this).outerWidth();
    navItemWidth.push(itemWidth);
    navItemVisible.push(true);
  });
  
  // add more link
  jQuery('#menu-main-menu').append('<li id="menu-more" class="menu-item menu-item-has-children" style="display: none;"><a id="menuMoreLink" href="#">More</a><ul id="moreSubMenu" class="sub-menu"></ul></li>');
  moreWidth = jQuery('#menu-more').outerWidth();
  
  // toggle sub-menu
  jQuery('#menuMoreLink').click(function(event) {
    event.preventDefault();
    jQuery('.menu-item-has-children:not(#menu-more)').removeClass('visible');
    jQuery(this).parent('.menu-item-has-children').toggleClass('visible');
  });
  
  // collapse all sub-menus when user clicks off
  jQuery('body').click(function(event) {
    if (!jQuery(event.target).closest('.menu-item').length) {
      jQuery('.menu-item-has-children').removeClass('visible');
    }
  });
  
  jQuery('.menu-item-has-children a').click(function(e) { e.stopPropagation(); });
  jQuery('.menu-item-has-children ul').click(function(e) { e.stopPropagation(); });
  jQuery('.menu-item-has-children li').click(function(e) { e.stopPropagation(); });
  
  // toggle all sub-menus
  jQuery('.menu-item-has-children').click(function(event) {
    if (!jQuery(this).hasClass('visible')) {
      jQuery(this).siblings('.menu-item-has-children').removeClass('visible');
      jQuery(this).addClass('visible');
    }
    else {
      jQuery(this).removeClass('visible');
    }
  });
  
  // format navigation on page load
  formatNav();
  
  // watch for difference between touchscreen and mouse
  watchForHover();
});

Formatting the Navigation

The real magic happens in the formatNav function. It loops through each top-level menu item, adding up the item widths and comparing them to the available space. If not enough space is available, it moves the item to the "more" sub-menu. If more space is available than there had been previously, it takes menu items out of the "more" sub-menu and re-inserts them into the top-level navigation.

function formatNav () {
  // initial variables
  var room = true;
  var count = 0;
  var tempWidth = 0;
  var totalWidth = 0; 
  var containerWidth = jQuery('.menu-main-menu-container').innerWidth();
  var navPadding = 5; // for spacing around items
  var numItems = navItems.length - 1;
  
  // for each menu item
  navItems.each(function () {
    // get width of menu with that item
    tempWidth = totalWidth + navItemWidth[count] + navPadding;
    
    // if the menu item will fit
    if (((tempWidth < (containerWidth - moreWidth - navPadding)) || ((tempWidth < (containerWidth)) && (count == numItems))) && (room == true)) {
      // update current menu width
      totalWidth = tempWidth;
      
      // show menu item
      if (navItemVisible[count] != true) {
        // move back to main menu
        jQuery('#menu-more').before(jQuery('#moreSubMenu').children().first());
        
        navItemVisible[count] = true;
        
        // if all are visible, hide More
        if (count == numItems) {
          jQuery('#menu-more').hide();
        }
      }
    }
    // if the menu item will not fit
    else {
      // if there is now no room, show more dropdown
      if (room == true) {
        room = false;
        
        // change text to "Menu" if no links are showing
        if (count == 0) {
          jQuery('nav').addClass('all-hidden');
          jQuery('#menuMoreLink').text("Menu");
        }
        else {
          jQuery('nav').removeClass('all-hidden');
          jQuery('#menuMoreLink').text("More");
        }
      
        jQuery('#menu-more').show();
      }
      
      // move menu item to More dropdown
      jQuery(this).appendTo(jQuery('#moreSubMenu'));
      
      navItemVisible[count] = false;
    }
    
    // update count
    count += 1;
  });
}

On Window Resize

Every time the page width changes, though, the menu changes as well. I created a function called onResize that handles such an event. Due to the use of Flexbox, which causes widths to vary, the width of each item must be re-calculated, after which the formatNav function can be called again. In order to keep this function from being called repeatedly in the event that a user is playing with the window size for awhile, I added a timeout to jQuery(window).resize so that onResize will be called only once the resizing has stopped for half a second.

/ format navigation on page resize
var id;
jQuery(window).resize(function() {
    clearTimeout(id);
    id = setTimeout(onResize, 500);
});

function onResize () {
  if(winWidth != jQuery(window).width()){
    // get width of each item, and list each as visible
    var count = 0
    navItems.each(function () {
      var itemWidth = jQuery(this).outerWidth();
      if (itemWidth > 0) {
        navItemWidth[count] = itemWidth;
      }
    });

    moreWidth = jQuery('#menu-more').outerWidth();

    // hide all submenus
    jQuery('.menu-item-has-children').removeClass('visible');

    formatNav();
    
    winWidth = jQuery(window).width();
  }
}

Hover Support

I originally designed the menu to work on click/touch, as hover menus require precision mousework for use. However, I soon discovered that users prefer having the option to hover, so I added a function to the JavaScript that would distinguish between a touchscreen and a mouse, adding a "has-hover" class to the body element when a user has hovering capabilities:

function watchForHover() {
  var hasHoverClass = false;
  var lastTouchTime = 0;

  function enableHover() {    
    // filter emulated events coming from touch events
    if (new Date() - lastTouchTime < 500) return;
    if (hasHoverClass) return;

    jQuery('body').addClass('has-hover');
    hasHoverClass = true;
  }

  function disableHover() {
    if (!hasHoverClass) return;

    jQuery('body').removeClass('has-hover');
    hasHoverClass = false;
  }

  function updateLastTouchTime() {
    lastTouchTime = new Date();
  }

  document.addEventListener('touchstart', updateLastTouchTime, true);
  document.addEventListener('touchstart', disableHover, true);
  document.addEventListener('mousemove', enableHover, true);

  enableHover();
}

I also added hover support to the CSS so that when hover is being used (body.has-hover) and an li.menu-item-has-children is being hovered, the sub-menu will have display: block; and position: relative; applied.

Conclusion

Putting it all together, here's a CodePen of the the final result:
@Flexbox Priority Navigation - Step 3: jQuery
CodePen_Screenshot_3.png

You will notice that this isn't perfect. Having sub-menus nested within a sub-menu that expand downward is fine when toggling via click, but when hovering, menu items jump and some can even be inaccessible. Indeed, sub-menus are generally problematic for priority navigations, which work best when there are no sub-menus to be dealt with. I tried a solution for this that opened the sub-menu only when the down arrow was hovered, which functioned but wasn't necessarily intuitive. Others have suggested moving sub-sub-menus to the right or left rather than expanding them within the sub-menu dropdown, thus shifting the difficulty to ensuring the menu items do not get lost off the page.

There are also many options for a priority navigation that are more lightweight than the one I have just outlined. Part of this is due to my determination to use Flexbox. Another part is because I've pulled the code from a fully-built site, constructed under real-world conditions with budgets and deadlines, and, while I cleaned out some unnecessary bits, I'm certain there's more I missed. Even taking all these factors into account, though, I'm sure others would be capable of accomplishing the same in fewer lines of code. So, by all means, take this and improve it!

Of course, this solution isn't for everyone, either. Not all people share my fixation with Flexbox. There are instances in which a tool like Flexbox isn't needed for a clean, well-spaced navigation.

But for those times when spacing and vertical alignment need to be more . . . flexible? Someone may find that this comes in handy.

Discover and read more posts from Mary Schieferstein
get started
post commentsBe the first to share your opinion
Знаковян Семён
2 years ago

Hi, Mary!
Thans for awesome article.
I have one unresolved issue with priority navs: How to prevent layout shift on initial page load?
Option with overflow: hidden on nearest parent is less preferable.
Of course, if we use React, we may use Portal for dropdown to handle overflow:hidden limitations.
My code, that use only inline-block (flex boxes did not worked for me) have exact the same issue with layout shift.

Mary Schieferstein
2 years ago

I’ve never seen the layout shift on page load. (Unless the entire page is taking awhile to load, but that just goes back to basic load time optimization principles.) When I pull up the final Codepen link it loads at the correct size. Can you provide an example so I can see the problem? I would guess that any shift would have something to do with the speed and order in which things are loaded. (For example, if the script is loaded at the end of the document rather than towards the beginning, if Internet speeds are slower, etc.)

Show more replies