Hostingheaderbarlogoj
Join InMotion Hosting for $3.49/mo & get a year on Tuts+ FREE (worth $180). Start today.
Advertisement

Big Menus, Small Screens: Responsive, Multi-Level Navigation

by
Gift

Want a free year on Tuts+ (worth $180)? Start an InMotion Hosting plan for $3.49/mo.

If you’ve ever worked on a responsive website, you’ve no doubt had to tackle one of the trickiest problems in this emerging field: navigation. For simple navigation, the solutions can be straight-forward. However, if you’re working on something a bit more complex, maybe with multiple nested lists and dropdowns, a more dramatic rearrangement may be in order.

In this approach to responsive navigation, we’re going to use an approach that can accommodate large, multi-level navigation menus using media queries and jQuery, whilst trying to keep our markup simple and our external resources minimal.

The Goal: Responsive Dropdown Menu

Here’s what we’re aiming for:

Goal
  • On larger screens, show a horizontal drop-down menu, with up to 2 levels of sub-menus that appear when parent element is hovered over.
  • On smaller screens, a “menu” button which displays our menu vertically, displaying sub-menus when parent element is clicked/touched.

Step 1: The Markup

Our markup is a fairly straight-forward unordered list, with nested lists contained within list items. I’m intentionally not using any classes or IDs on any element but the parent unordered list, so that the menu will be compatible with CMS-generated menus.

<div class="container">
	
<a class="toggleMenu" href="#">Menu</a>
<ul class="nav">
	<li  class="test">
		<a href="#">Shoes</a>
		<ul>
			<li>
				<a href="#">Womens</a>
				<ul>
					<li><a href="#">Sandals</a></li>
					<li><a href="#">Sneakers</a></li>
					<li><a href="#">Wedges</a></li>
					<li><a href="#">Heels</a></li>
					<li><a href="#">Loafers</a></li>
					<li><a href="#">Flats</a></li>
				</ul>
			</li>
			<li>
				<a href="#">Mens</a>
				<ul>
					<li><a href="#">Loafers</a></li>
					<li><a href="#">Sneakers</a></li>
					<li><a href="#">Formal</a></li>
				</ul>
			</li>
		</ul>
	</li>

Step 2: Basic Styling

Let’s add some very basic styling to get our list looking like a navigation bar:

body, nav, ul, li, a  {margin: 0; padding: 0;}
body {font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; }
a {text-decoration: none;}
.container {
    width: 90%;
    max-width: 900px;
    margin: 10px auto;
}
.toggleMenu {
    display:  none;
    background: #666;
    padding: 10px 15px;
    color: #fff;
}
.nav {
    list-style: none;
     *zoom: 1;
     background:#175e4c;
     position: relative;
}
.nav:before,
.nav:after {
    content: " "; 
    display: table; 
}
.nav:after {
    clear: both;
}
.nav ul {
    list-style: none;
    width: 9em;
}
.nav a {
    padding: 10px 15px;
    color:#fff;
    *zoom: 1;
}
.nav > li {
    float: left;
    border-top: 1px solid #104336;
    z-index: 200;
}
.nav > li > a {
    display: block;
}
.nav li ul {
    position: absolute;
    left: -9999px;
    z-index: 100;
}
.nav li li a {
    display: block;
    background: #1d7a62;
    position: relative;
    z-index:100;
    border-top: 1px solid #175e4c;
}
.nav li li li a {
    background:#249578;
    z-index:200;
    border-top: 1px solid #1d7a62;
}
step 2

We’ve just floated our list items into a horizontal line, set some colors up and hidden the submenus off the screen using absolute positioning. If you’re wondering about line 20, it’s a simple clearfix method that I find effective in these kinds of situations (read more on Nicolas Gallagher's blog).

Step 3: Horizontal Dropdown Menu

Next, let’s get the horizontal dropdown menus happening. While this could be done with pure CSS using the :hover pseudo-selector, I’m going to add it in using JavaScript since we’re going to be switching the menus to activate on click in our small-screen version.

Since we’re using absolute positioning to move our submenus off the screen, let’s add some .hover rules that will position the submenus relative to their parents when the .hover class is present (which we’ll take care of with jQuery).

.nav li {
    position: relative;
}
.nav > li.hover > ul {
    left: 0;
}
.nav li li.hover ul {
    left: 100%;
    top: 0;
}

Now we’ll add a couple of lines of simple jQuery to add the .hover class to list elements when they’re hovered over.

$(document).ready(function() {
	$(".toggleMenu").css("display", "none");
	$(".nav li").hover(function() {
	 	$(this).addClass('hover');
	}, function() {
		$(this).removeClass('hover');
	});
});
step 3

And just like that, we have ourselves a functional, multi-level dropdown menu.

Step 4: Vertical Dropdown Menu

Our lovely horizontal dropdown menu unfortunately looks quite tiny on mobile screens, so let’s make sure mobile browsers will be fully zoomed-in when they load our page by adding the viewport meta tag.

<meta name="viewport" content="width=device-width, initial-scale=1">

Of course, now our dropdown menu looks even worse on mobile devices, and most of it doesn’t even fit on the screen! Let’s add in some media queries to style our list into a vertical list below our breakpoint. Our breakpoint is determined by the point at which our menu breaks onto two lines, in my case, that’s about 800px.

At our breakpoint, we’ll remove the floats and set the list items and unordered lists to width: 100%. Right now, when we hover over our parent items, their child lists are displayed over top of the items beneath it. We’d rather the other top-level list items get displaced. So instead of changing the left position of the unordered list, we’ll just set the position value to static.

@media screen and (max-width: 800px) {
    .nav > li {
        float: none;
    }
    .nav ul {
        display: block;
        width: 100%;
    }
   .nav > li.hover > ul , .nav li li.hover ul {
        position: static;
    }
}
step 4

Step 5: Converting Hover to Click

Since you can't hover on a touch screen (yet), we're going to create some conditional code to check the width of the window, then write the code to set the click() and hover() events.

$(document).ready(function() {
	var ww = document.body.clientWidth;
	if (ww < 800) {
		$(".toggleMenu").css("display", "inline-block");
		$(".nav li a").click(function() {
			$(this).parent("li").toggleClass('hover');
		});
	} else {
		$(".toggleMenu").css("display", "none");
		$(".nav li").hover(function() {
			$(this).addClass('hover');
		}, function() {
			$(this).removeClass('hover');
		});
	}
});

For the click event, we've had to change the targeted element from the list item to the parent item, since the lists are nested and clicking one list item could open its grandchildren as well. The problem with this change, however, is that clicking an anchor tag will reload the page, but we can't prevent default behavior on all anchor tags that are descendants of list items.

To fix this, let's add a short bit of jQuery to add the class .parent to any list item whose child anchor has siblings, then target only these anchors (again, trying to keep our markup flexible).

$(".nav li a").each(function() {
		if ($(this).next().length > 0) {
			$(this).addClass("parent");
		};
	})
	if (ww < 800) {
		$(".toggleMenu").css("display", "inline-block");
		$(".nav li a.parent").click(function(e) {
			e.preventDefault();
		 	$(this).parent("li").toggleClass('hover');
		});
	} else {
... }

Step 6: Toggle the Menu

Our menu looks pretty nice on mobile devices now, but it's taking up quite a lot of valuable screen real estate. A popular new approach has been to toggle navigation lists using a button, usually with the word "Menu" or a menu icon. Let's get our toggle link working to show our navigation list only when clicked.

$(".toggleMenu").click(function(e) {
	e.preventDefault();
	$(".nav").toggle();
});

if (ww < 800) {
	$(".toggleMenu").css("display", "inline-block");
	$(".nav").hide();
} else {
	...
}
step 6

Step 7: A Bit More Style

Since we now have our parent list items targeted with a class, why not add a little down arrow to let our users know which list items have children?

.nav > li > .parent {
    background-position: 95% 50%;
}
.nav li li .parent {
    background-image: url("images/downArrow.png");
    background-repeat: no-repeat;
    background-position: 95% 50%;
}
@media screen and (max-width: 800px) {
 .nav > li > .parent {
        background-position: 95% 50%;
    }
    .nav li li .parent {
        background-image: url("images/downArrow.png");
        background-repeat: no-repeat;
        background-position: 95% 50%;
    }
}
step 7

Bonus: Showing Off

Now, for practical purposes, this menu works quite well. If you open it up in a mobile browser, you'll get a very usable vertical accordion list, if you open it in your desktop browser, you'll get a nice horizontal drop-down list. If you resize your desktop browser down to mobile widths, however, our navigation still only works on hover, and the menu isn't hidden with the toggle feature. For most applications, this is fine. After all, your average web site visitor doesn't grab the edge of their browser and start dragging madly back and forth.

However, if you're looking to impress your fellow web professionals, this just won't do. We'll need to set up our script to respond to the resize event, and execute our conditional code when the browser is resized below the breakpoint. Expanding on the code demonstrated in the excellent Creating a Mobile-First Responsive Design tutorial, we're going to set some variables to keep track of and update our browser width.

Step 8: Window Events

Since we want to check the width of the browser both on page load and when the browser is resized, let's start by moving all the conditional code out of the $(document).ready() event and wrapping it in a function adjustMenu.

var ww = document.body.clientWidth;
$(document).ready(function() {
	$(".toggleMenu").click(function(e) {
		e.preventDefault();
		$(".nav").toggle();
	});
	$(".nav li a").each(function() {
		if ($(this).next().length > 0) {
			$(this).addClass("parent");
		};
	})
	adjustMenu();
});
function adjustMenu() {
	if (ww < 800) {
		$(".toggleMenu").css("display", "inline-block");
		$(".nav").hide();
		$(".nav li a.parent").click(function(e) {
			e.preventDefault();
		 	$(this).parent("li").toggleClass('hover');
		});
	} else {
		$(".toggleMenu").css("display", "none");
		$(".nav li").hover(function() {
		 		$(this).addClass('hover');
			}, function() {
				$(this).removeClass('hover');
		});
	}
}

To call the function as the browser is resized, we're going to bind it to the window events resize and orientationchange. Inside this event we're going to redefine the ww variable to adjust to the browser's new width.

$(window).bind('resize orientationchange', function() {
	ww = document.body.clientWidth;
	adjustMenu();
});

At this point, we've introduced more problems: though this initially appears to work (the horizontal menu collapses into the "menu" button which opens the menu), it's quickly evident we have two big issues:

  1. The entire menu disappears if we resize the mobile-width window back out past the break point.
  2. The hover event is still firing on the mobile version.

Step 9: Showing and Hiding

Our missing navigation menu seems like an easy fix: just add $("nav").show() under the greater-than-breakpoint condition. This solution seems to work, but brings up some tricky edge cases. Since the code is re-evaluated every time the browser is resized, whenever we resize with the menu open, it automatically closes again.

This might seem like an unlikely edge case, but mobile browsers are weird: for example, on my Galaxy S, scrolling down, then back up to the top of a page triggers the resize event. Not good!

To fix this, we need to have some way of checking whether the menu toggle has been clicked. I'm going to use an added class on the menu toggle button, because it could be handy for styling (maybe we want a down arrow later down the line?) In addition to toggling the display of the navigation menu, the menu toggle button will now toggle its own class of .active. Back in our narrower-than-breakpoint condition, let's update the code to hide our navigation menu only if the menu toggle does not have a class of .active.

$(document).ready(function() {
	$(".toggleMenu").click(function(e) {
		e.preventDefault();
		$(this).toggleClass("active");
		$(".nav").toggle();
	});
});
	if (ww < 800) {
		$(".toggleMenu").css("display", "inline-block");
		if (!$(".toggleMenu").hasClass("active")) {
			$(".nav").hide();
		} else {
			$(".nav").show();
		}
		$(".nav li a.parent").click(function(e) {
			e.preventDefault();
		 	$(this).parent("li").toggleClass('hover');
		});
	} ...

Step 10 Unbinding Hover Events

To solve our problem of the mobile-sized navigation menu responding to hover events, we just have to unbind() the hover event from our list items inside the narrower-than-breakpoint condition.

$(".nav li").unbind('mouseenter mouseleave');

However, this illuminates a new problem: our click events don't work if you resize the browser from big to small. Some debugging reveals that the click event has been bound to the link a bunch of times, so as soon as we click, the .hover class is toggled on and then immediately off again. This happens because the whole function fires repeatedly as you're resizing the window. To make sure we start toggling from the right place, we need to unbind the click event before re-binding it again.

Once we resize the browser from small to big again, however, we're now missing our hover event, because we unbound it when the browser was small, and our click event is still there, so let's unbind before binding our hover statement too. We're also going to remove any list items with a class of .hover before we add them back in on the hover event, to prevent menus from awkwardly staying open as we make the browser wider.

I'm rewriting the .click() and .hover() events using .bind() for clarity's sake. It means the same exact thing.

if (ww < 800) {
	$(".toggleMenu").css("display", "inline-block");
	if (!$(".toggleMenu").hasClass("active")) {
		$(".nav").hide();
	} else {
		$(".nav").show();
	}
	$(".nav li").unbind('mouseenter mouseleave');
	$(".nav li a.parent").unbind("click").bind("click", function(e){
		e.preventDefault();
	 	$(this).parent("li").toggleClass('hover');
	});
} else {
	$(".toggleMenu").css("display", "none");
	$(".nav").show();
            $(".nav li").removeClass("hover");
	$(".nav li a").unbind("click");
	$(".nav li").unbind('mouseenter mouseleave').bind('mouseenter mouseleave', function() {
		$(this).toggleClass('hover');
	});
}

Hooray! It all seems to work as it ought to.

Step 11: Get IE to Behave

It wouldn't be a party if IE7 didn't come along to crash it, now would it? We've got an odd bug here where our sub-menus disappear when they're displayed over other content (in our example, some lorem ipsum text). Once the cursor reaches the paragraph element, *poof* no more menu. I'm fairly certain this is due to some weirdness in the way IE7 deals with position: relative;, and the issue is easily solved by triggering hasLayout on the .nav a element.

.nav a {
    *zoom: 1;
}

Further Considerations

As always, you'll have to make your own judgment call about browser and feature support, but tools like Modernizr and respond.js can take some of the pain out of supporting older browsers.

I've tested this menu out on Mobile Safari and every Android 2.3 browser I could get my hands on, and it seems to work quite well. However, this technique is very JavaScript-dependent, and since some mobile browsers (Blackberry generally) have very poor support for JavaScript, we might be leaving some users with an unusable navigation menu.

Fortunately, there are a number of methods you can use to serve simplified layouts to JavaScript-less devices. The good old-fashioned technique of adding a .no-js class to the body tag and removing it in your JavaScript comes to mind, but you could also just provide href attributes for the top-level navigation items, sending users to the general "shoes" category listing for example, and relying on preventDefault to prevent this behavior in JavaScript-enabled devices.

Of course the media queries won't work in older versions of IE, so you'll have to decide whether it's worth including a polyfill such as respond.js to fill this gap.

Last but not least, there's that pesky iOS bug that causes the zoom level to change when you rotate the device. Check out the iOS Orientationchange Fix script to squash this bug.

Further Reading

Though this technique may be well-suited to certain situations and menu structures, there are still a lot of other options out there for taming navigation on mobile devices. For example:

Feel free to peruse, clone, or fork the GitHub repo, and thanks for reading!

Advertisement