DEV Community

loading...

Build a Responsive Nav BBC & Smashing Magazine Style!!

tmns
pentester -> humanitarian volunteer -> developer
Updated on ・9 min read

Context

While re-designing a friend's site from scratch recently I decided I wanted to do something different than the ol' hamburger for a responsive nav. I really like how Smashing Magazine has designed theirs - with nav items gradually disappearing from the main nav and appearing within the secondary nav as the viewport width decreases.

For a while I just assumed they were using JavaScript; however, after turning JS off while browsing their site I was surprised to see it still working! It turns out they use grid and a series of media queries to get their desired effect. I've recreated the basic functionality in this codepen - feel free to use it as a reference for building your own!

BBC goes the JS route but with progressive enhancement so things don't break completely under JS-less conditions. Thankfully, some wonderful folks there have published guidelines and mini-tutorials for many of their components (with accessibility in mind!!). If I'm not mistaken, the supernaturally awesome Heydon Pickering participated in this, so you know it's quality!

The BBC folks call this component the Masthead and it is a requirement that it still serves its purpose (ie, to provide users with a set of navigation links) under the following circumstances:

  1. No JavaScript: The functionality must be available to users whose browsers are not running JavaScript
  2. JavaScript but no IntersectionObserver: The IntersectionObserver API is the most efficient way to allot a suitable number of promoted navigation links within the available space. These links must still be available where the browser does not support IntersectionObserver.

With those rules in mind, let's get started building our own version! At any time throughout the process you can refer to this codepen to ensure you're on the right track!

Adding some HTML & CSS

Since we want to progressively enhance our nav, we will start out by building the most simple version that will always display all nav items, in order to ensure that all users will be able to access all of our information!

Let's begin with the HTML:

<header>
  <nav aria-label="Our Site">
    <a href="">
      <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
        <path d="M12 14l9-5-9-5-9 5 9 5z"></path>
        <path d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z"></path>
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222"></path>
      </svg>
    </a>
    <div class="main-nav">
      <ul>
        <li data-index="0">
          <a href="">Link 1</a>
        </li>
        <li data-index="1">
          <a href="">Link 2</a>
        </li>
        <li data-index="2">
          <a href="">Link 3</a>
        </li>
        <li data-index="3">
          <a href="">Link 4</a>
        </li>
        <li data-index="4">
          <a href="">Link 5</a>
        </li>
        <li data-index="5">
          <a href="">Link 6</a>
        </li>
        <li data-index="6">
          <a href="">Link 7</a>
        </li>
        <li data-index="7">
          <a href="">Link 8</a>
        </li>
        <li data-index="8">
          <a href="">Link 9</a>
        </li>
        <li data-index="9">
          <a href="">Link 10</a>
        </li>
        <li data-index="10">
          <a href="">Link 11</a>
        </li>
      </ul>
    </div>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

That's it, a header where I'll apply some wrapper-type styles, the first svg I saw from Heroicons, and the nav itself consisting of a ul with eleven items. Note we're also adding a custom data-index attribute to each list item, which we will use later when toggling styles with IntersectionObserver.

Now, you will probably notice that it's not the prettiest nav in the world; in fact, it looks just like a measly ol' list (because that's all a nav typically is!). Let's improve that a bit by adding some CSS (keep in mind, the styles here are written with Codepen's "Reset" base styles also applied):

header {
  position: relative;
  margin-right: auto;
  margin-left: auto;
  background-color: burlywood;
  padding: 1rem 4rem 2rem;
  max-width: 64rem;
  color: darkslategray;
  font-family: Arial, Helvetica, sans-serif;
  font-weight: 900;
}

nav {
  display: flex;
  position: relative;
  align-items: center;
  justify-content: center;
}

svg {
  width: 5rem;
  height: 5rem;
}

nav div {
  margin-left: 3rem;
}

ul {
  display: flex;
  flex-wrap: wrap;
  list-style: none;
  padding: 1rem;
}

li {
  padding-top: 1rem;
  padding-left: 2rem;
}

a {
  display: block;
  position: relative;
  text-decoration: none;
  white-space: nowrap;
  color: inherit;
}

a:focus {
  outline: 2px dashed white;
  outline-offset: 0.25rem;
}

ul a:hover {
  transform: rotate(-3deg);
}
Enter fullscreen mode Exit fullscreen mode

Okay! That's a little nicer now! It should be noted here that all of the styles (eg spacing, color, hover effects) are completely arbitrary. The only real important bit is the setting of flex and flex-wrap: wrap on our ul. This enables the nav items to wrap as the viewport width decreases. And with that, our most basic responsive nav is complete!

Alternatively, if you want to avoid flex you can accomplish the same wrapping behavior by going old school and setting float: left on all our elements. We'll stick with flex here though.

Now let's add some extra functionality in the case that the user has JS enabled. We can add another item to the main nav that will act as a type of secondary nav, where we will "hide" items from the main nav as the viewport width decreases.

Let's place the markup for this extra item before the closing tag for our nav:

[...]
    </div>
    <div class="nav__more-item" style="display: none;">
      <a href="#secondary-nav" role="button" aria-haspopup="true" aria-expanded="false">
        <span class="visually-hidden">More</span>
        <span aria-hidden="true">More</span>
      </a>
    </div>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

Note that the entire div has a style attribute with display: none;. Remember, if the user does not have JS, we don't want to show this piece of functionality; so, we set its default display value to none and we will change it later via JS.

The CSS for this piece will consist of a little hover effect and an accessible way of visually hiding an element, which I learned from Andy Bell:

.nav__more-item {
  padding-top: 1rem;
}

.nav__more-item a:hover {
  background: darkslategray;
  color: burlywood;
}

.visually-hidden {
  position: absolute;
  margin: 0;
  border: 0;
  padding: 0;
  width: 1px;
  height: auto;
  overflow: hidden;
  white-space: nowrap;
  clip: rect(0 0 0 0);
}
Enter fullscreen mode Exit fullscreen mode

Now let's add the HTML for the actual menu itself, which we will place right outside our nav, before the closing header tag:

[...]
  </nav>
  <div id="secondary-nav" class="secondary-nav" style="display: none;">
    <ul class="secondary-nav__list">
      <li data-index="0">
        <a href="">Link 1</a>
      </li>
      <li data-index="1">
        <a href="">Link 2</a>
      </li>
      <li data-index="2">
        <a href="">Link 3</a>
      </li>
      <li data-index="3">
        <a href="">Link 4</a>
      </li>
      <li data-index="4">
        <a href="">Link 5</a>
      </li>
      <li data-index="5">
        <a href="">Link 6</a>
      </li>
      <li data-index="6">
        <a href="">Link 7</a>
      </li>
      <li data-index="7">
        <a href="">Link 8</a>
      </li>
      <li data-index="8">
        <a href="">Link 9</a>
      </li>
      <li data-index="9">
        <a href="">Link 10</a>
      </li>
      <li data-index="10">
        <a href="">Link 11</a>
      </li>
    </ul>
  </div>
</header>
Enter fullscreen mode Exit fullscreen mode

Again, note we are setting its default display to none.

Now let's add some CSS just to make it a little nicer on the eyes:

.secondary-nav__list {
  position: absolute;
  top: 75%;
  right: 0;
  flex-direction: column;
  align-items: center;
  transform: translateX(-7%);
  border-radius: 0.77rem;
  box-shadow: 0 13px 27px -5px rgba(50, 50, 93, 0.25),
    0 8px 16px -8px rgba(0, 0, 0, 0.3), 0 -6px 16px -6px rgba(0, 0, 0, 0.025);
  background-color: darkslategray;
  padding: 1rem 2rem 2rem;
  min-width: 5rem;
  height: auto;
  color: burlywood;
}

.secondary-nav__list::before {
  content: '';
  display: block;
  position: absolute;
  top: 1px;
  left: 50%;
  transform: translate(-50%, -100%);
  border-right: 15px solid transparent;
  border-bottom: 15px solid darkslategray;
  border-left: 15px solid transparent;
  width: 0;
  height: 0;
}

.secondary-nav__list li {
  padding-left: 0;
}
Enter fullscreen mode Exit fullscreen mode

And with that we've actually written all the HTML and CSS necessary for our nav. From here on out we will just be applying bits of CSS via JS. Let's get to it!

Adding some JS

Nearly all of the JS here is directly based on the JS written for the demo masthead linked above. So all credit goes to the folks who had a helping hand in creating it. It's also included unminified and with helpful comments, so check it out if you need more clarification on something.

Let's kick our version off by setting some essential styles. We will:

  • Set overflow-x of our nav to auto so the user will be able to still scroll and see all the menu items if their browser does not support IntersectionObserver . This style will be set to hidden later if IntersectionObserver is supported.
  • Set flex-wrap of the nav list to nowrap so the items don't just simply wrap when the viewport width decreases.
  • Set display block on our "More" nav item.
var mainNav = document.querySelector('.main-nav');
mainNav.style.overflowX = 'auto';
mainNav.firstElementChild.style.flexWrap = 'nowrap';

var moreMenuItem = document.querySelector('.nav__more-item');
moreMenuItem.style.display = 'block';
Enter fullscreen mode Exit fullscreen mode

Great! You should now be seeing the More nav item we added in the previous section. Now let's add an event listener to it that shows / hides the secondary nav menu on click:

var moreMenuLink = moreMenuItem.querySelector('a');
var secondaryNav = document.querySelector('.secondary-nav');

moreMenuLink.addEventListener('click', function (e) {
  e.preventDefault();

  var expanded = moreMenuLink.getAttribute('aria-expanded') === 'true' || false;
  moreMenuLink.setAttribute('aria-expanded', !expanded);

  var open = secondaryNav.style.display === 'flex' || false;
  secondaryNav.style.display = open ? 'none' : 'flex';

  if (!open) {
    var firstVisibleItem = secondaryNav.querySelector('li[style="display: block;"] a');
    firstVisibleItem.focus();
  }
});
Enter fullscreen mode Exit fullscreen mode

Let's also add two keydown event listeners: one on the menu link to make it behave like a button and open the secondary nav on space and one on the secondary nav to close on esc:

moreMenuLink.addEventListener('keydown', function (e) {
  if (e.keyCode === 32) {
    e.preventDefault();
    moreMenuLink.click();
  }
});

secondaryNav.addEventListener('keydown', function (e) {
  if (e.keyCode === 27) {
    e.preventDefault();
    moreMenuLink.setAttribute('aria-expanded', false);
    secondaryNav.style.display = 'none';
    moreMenuLink.focus();
  }
});
Enter fullscreen mode Exit fullscreen mode

Adding some IntersectionObserver

Now has come the time for the grand finale - enter stage left the IntersectionObserver!

This article assumes a level of familiarity with the concept and functionality of the API - if you're looking for an in-depth explanation I highly recommend this really great write up by Heather Weaver.

First, let's ensure its supported by the browser and turn off the overflow-x we had previously set on the main nav if it is. Let's also configure the settings for our observer while we're at it. Note, we are setting the root property of the observer to the div that wraps the main nav ul:

if ('IntersectionObserver' in window) {
  mainNav.style.overflowX = 'hidden';

  var observerSettings = {
    root: mainNav,
    threshold: 0.98
  }
Enter fullscreen mode Exit fullscreen mode

Now let's define the function that we'll pass to the observer to be called every time an intersection occurs:

  var secondaryNavItems = secondaryNav.querySelectorAll('li');

  var callback = function (items, observer) {
    Array.prototype.forEach.call(items, function(item) {
      var index = parseInt(item.target.dataset.index);
      if (item.intersectionRatio > 0.98) {
        item.target.style.visibility = 'visible'; 
        secondaryNavItems[index].style.display = 'none';
      } else {
        item.target.style.visibility = 'hidden';
        secondaryNavItems[index].style.display = 'block';
      }
    });    
  };
Enter fullscreen mode Exit fullscreen mode

We determine which item to display in the secondary nav by checking the value of the observed item's data-index attribute, which is its 0-index of its render order. Since both list's items are rendered in the same order, we can use its index to find the correct item in the secondary nav list.

Finally, we initialize our observer with our settings and callback and call observe on each of our main nav items:

  var mainNavItems = mainNav.querySelectorAll('li');

  var observer = new IntersectionObserver(callback, observerSettings);
  Array.prototype.forEach.call(mainNavItems, function(item) {
    observer.observe(item);
  });
}
Enter fullscreen mode Exit fullscreen mode

And voila! Just like that we are finished with our observer as well as our entire responsive, hamburgerless nav bar!!

Side note: I just realized I could have been making vegetarian puns this whole time insert facepalm emoji

Conclusion

I really love figuring out how things like this work, aside from the momentary fits of rage that occur when I set flex on the wrong element. So I hope this little article decreases the number of other folks' fits of rage and helps out anyone that is interested in building something similar.

Feel free to reach out if you have any questions or comments. & have fun!!

Discussion (0)