@mdo

Horizontal scrolling nav

March 10, 2022

Continuing with my JavaScript fun, I wanted to do an updated pass on the horizontal navs I’ve previously built for both GitHub Mobile (prior to the current responsive version and native apps) and this Bootstrap dashboard example.

The goal? Build a responsive horizontal navigation with an overflow scroll that automatically scrolls the active nav link into view. The responsive part is that the navigation links scroll horizontally—easy enough, and scales decently well across all viewports. The scrolling part and active link setting is done in JS and, thanks to work on the upcoming redesign of Bootstrap’s docs, relatively straightforward.

Here’s the final demo via CodePen.

See the Pen Scrolling nav by Mark Otto (@emdeoh) on CodePen.

Here’s a look at what’s under the hood. For the HTML, I’m using the barebones: a <nav>, <ul>, and the nav links. The <nav> is what scrolls, while the <ul> holds the links to a single line at all times.

<header>
  <h1>Demo</h1>
  <nav>
    <ul>
      <li><a href="#">Home</a></li>
      <li><a href="#">World</a></li>
      <li><a href="#">Politics</a></li>
      <li><a href="#">Business</a></li>
      <li><a href="#">Opinion</a></li>
      <li><a href="#" class="active">Tech</a></li>
      <li><a href="#">Science</a></li>
      <li><a href="#">Health</a></li>
      <li><a href="#">Sports</a></li>
      <li><a href="#">Arts</a></li>
      <li><a href="#">Books</a></li>
      <li><a href="#">Style</a></li>
      <li><a href="#">Food</a></li>
      <li><a href="#">Travel</a></li>
    </ul>
  </nav>
</header>

The CSS for the <nav> and <ul> get a little clever, though. I set a fixed height (equalling that of the computed link height) and overflow-y on the <nav> to “crop” the links within. But, that doesn’t stop the annoyance of scrollbars appearing on scroll and thus covering up the links. So, among some additional baseline styles, the <ul> has a large padding-bottom value to push any visible scrollbar out of sight. This has it’s own usability concerns—scrollbars show that something can scroll, how much content there is, etc—but there’s no elegant way to address this across all browsers and devices.

nav {
  position: relative;
  z-index: 2;
  height: 2.5rem;
  overflow-y: hidden;
  scroll-behavior: smooth;
}

ul {
  display: flex;
  flex-wrap: nowrap;
  padding-bottom: 1.5rem;
  padding-left: 0;
  margin-block: 0;
  overflow-x: auto;
  list-style: none;
  text-align: center;
  white-space: nowrap;
}

On the JavaScript side, we get started with querying for our selector, check if it exists in the page, and set some variables for the navigation links and active link.

let nav = document.querySelectorAll('nav')

if (nav) {
  let navLinks = document.querySelectorAll('nav a')
  let activeLink = document.querySelector('.active')

  //
}

Our first line of JS then is to immediately (on page load) scroll the active link into view of the containing element. In the CodePen, I set a link that should be offscreen as the .active element so anyone can see the scroll happen on page load. During my work on the Bootstrap docs, I found a helpful JS method for this, scrollIntoView.

let nav = document.querySelectorAll('nav')

if (nav) {
  let navLinks = document.querySelectorAll('nav a')
  let activeLink = document.querySelector('.active')
  activeLink.scrollIntoView({ behavior: "smooth", inline: "center" })

  //
}

The behavior tells the browser how to animate the scroll while inline tells the browser to align the scroll horizontally. (For vertical alignment, if needed, there’s the block parameter.) From here, we need to add an event listener to each nav link to set the .active class and scroll the clicked link into view. This last bit of code could be cleaned up with a scrollIntoView function to de-dupe that line, but it didn’t feel worthwhile at the moment and adds a little more JS.

let nav = document.querySelectorAll('nav')

if (nav) {
  let navLinks = document.querySelectorAll('nav a')
  let activeLink = document.querySelector('.active')
  activeLink.scrollIntoView({ behavior: "smooth", inline: "center" })

  navLinks.forEach( function(link) {
    link.addEventListener("click", (event) => {
      navLinks.forEach( function(link) {
        link.classList.remove('active')
      })
      link.classList.add('active')
      link.scrollIntoView({ behavior: "smooth", inline: "center" })
    })
  })
}

Put it all together, you have a functional side-scrolling, responsive navigation. You may not even need the .active class toggling, but it completes the demo here, so I’ve included it for the sake of completeness.