UP | HOME

Simple Nav Bar
#web

Table of Contents

For a static website, a full-featured router is somewhat overkill. I'll demonstrate how to build a responsive, collapsible nav bar using pure javascript and css.

The nav bar will be an unordered list of links:

<html>
  <head>
    <title>Nav Bar Example</title>

    <!-- Load the 'League Spartan' font from google -->
    <link
      href="https://fonts.googleapis.com/css2?family=League+Spartan&display=swap"
      rel="stylesheet"
    />

    <!-- Set viewport so the webpage is not
         automatically scaled down for mobile devices -->
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <style>
      /* Set css color vars */
      :root {
          --background: #6b2e2e;
          --foreground: #fffee6;
          --gray: #524751;
          --light-gray: #8a7588;
      }

      body {
          background: var(--background);
      }

      #nav li a {
          color: var(--foreground);
          font-family: 'League Spartan', sans-serif;
      }
    </style>
  </head>
  <body>
    <ul id="nav">
      <li><a>Contact</a></li>
      <li><a>Brands</a></li>
      <li><a>Events</a></li>
      <li><a>About</a></li>
    </ul>
  </body>
</html>

init.png

Figure 1: Initial web page

Router

Setup

The router will use the history web api which requires a web server. I recommend http-server from npm, which can be installed by first installing nodejs and running the command npm install --global http-server, which adds the command http-server to your path.

Navigate a terminal to the location of the index.html file above and run http-server -o, which will start the web server and open the browser to the correct url to reach the server.

goto

In order to change routes without reloading the webpage, I need to implement a goto function which uses the history api to change the url bar.

<script>
  function goto(route) {
    // Change url bar to new route
    history.pushState(null, '', route);
  };
</script>

This script should be in the head of the document in order to load before the page renders.

The pushState function takes a state object, an empty string, and a route which will replace the url. The empty string is there for historical reasons. I don't need to keep track of any state in a static website so null will work fine here.

I'll add this function to each anchor element in the template:

<ul id="nav">
  <li><a onclick="goto('contact')">Contact</a></li>
  <li><a onclick="goto('brands')">Brands</a></li>
  <li><a onclick="goto('events')">Events</a></li>
  <li><a onclick="goto('about')">About</a></li>
</ul>

Clicking on each list item should now change the url bar to the correct location.

router-links.gif

Figure 2: Goto function

data-current-route attribute

The router itself will actually be an attribute on the body element of the webpage called data-current-route. Storing the current route in the template of the website allows for css selectors to hide and show content based on the current route.

Ideally, the data-current-route attribute should be set before the page loads but unfortunately the body element doesn't exist then. The next best thing is to update the current route on the DOMcontentLoaded event, which fires just before first paint.

function updateRouter() {}

document.addEventListener('DOMContentLoaded', updateRouter);

The updateRouter function, then, has to get the current route and set it as the attribute on the body element:

function updateRouter() {
  let route = location.pathname.slice(1); // Remove leading slash
  document.body.setAttribute('data-current-route', route);
}

Testing the router

In order to test this, http-server needs to serve index.html regardless of the route requested. This can be done by proxying unhandled requests:

http-server --port 8080 --proxy http://127.0.0.1:8080?

Short version:

http-server -P http://127.0.0.1:8080?

If using nginx instead of http-server, the try_files directive needs to fall back to index.html when a request can't be resolved:

server {
    ...

    location / {
	index index.html;
	try_files $uri $uri.html /index.html;
    }
}

With the web server running, navigating to http://localhost:8080/contact should add a data-current-route attribute to the body with a value of contact.

current-route.png

Figure 3: data-current-route body attribute

Showing and hiding content based on current route

I'll add some content to the webpage to hide and show based on the current route. Each page will have an attribute data-route to allow the css to hide and show elements.

...
</ul>
<div data-route="contact">
  <h3>Contact</h3>
</div>
<div data-route="brands">
  <h3>Brands</h3>
</div>
<div data-route="events">
  <h3>Events</h3>
</div>
<div data-route="about">
  <h3>About</h3>
</div>

I'll style the headlines the same as the nav items.

#nav li a,
h3 {
    color: var(--foreground);
    font-family: 'League Spartan', sans-serif;
}

And add a flexbox to the body to center the content:

body {
    background: var(--background);

    /* Center items and stack them on top of each other */
    display: flex;
    flex-direction: column;
    align-items: center;
}

content.png

Figure 4: All content displayed at once

I'll hide the pages that don't match the data-current-route attribute using display: none;

/* Hide all routes that are not equal to the current-route */
[data-current-route='contact'] [data-route]:not([data-route='contact']),
[data-current-route='brands'] [data-route]:not([data-route='brands']),
[data-current-route='events'] [data-route]:not([data-route='events']),
[data-current-route='about'] [data-route]:not([data-route='about']) {
  display: none;
}

Now, when navigating to http://localhost:8080/contact, only the Contact div should show, and so on for the rest of the routes.

The updateRouter function needs to be executed in two other scenarios: the history api's pushState and popState events. These change the url bar without reloading the page, so DOMContentLoaded event will not be fired. For pushState, I can modify the goto function, and for popState I can add an event listener on the window:

function goto(route) {
  // Change url bar to new route
  history.pushState(null '', route);
  updateRouter();
}

window.onpopstate = updateRouter;

Each link should now change the content of the website, and navigating 'back' will work as well.

goto-and-back.gif

Figure 5: Navigating forward and backward

Fallback page

The router is not smart enough to filter out bad requests yet. It needs to know all available routes and the one that is considered default to show when the user requests the main page. I need to modify both updateRouter and goto to handle unknown url requests. For this example, the contact route should be the fallback content to show.

const routes = ['contact', 'brands', 'events', 'about'];
const fallback = 'contact';

function updateRouter() {
  let route = window.location.pathname.slice(1); // Remove leading slash

  // Default to fallback page if route is not recognized
  if (!routes.includes(route)) route = fallback;

  document.body.setAttribute('data-current-route', route);
}

function goto(route) {
  history.pushState(null, '', route === fallback ? '/' : route);
  updateRouter();
}

Now, navigating to http://localhost:8080 will show the contact page, and all unknown urls will also show the contact page. As an exercise to the reader, a 404 route can be added for unknown routes instead.

Highlighting the current route in the menu

Each menu item should highlighted in light gray if the route matches the current route. I'll add data-route to each anchor element:

<ul id="nav">
  <li><a data-route="contact" onclick="goto('contact')">Contact</a></li>
  <li><a data-route="brands" onclick="goto('brands')">Brands</a></li>
  <li><a data-route="events" onclick="goto('events')">Events</a></li>
  <li><a data-route="about" onclick="goto('about')">About</a></li>
</ul>

Because of the earlier css rule that hides all data-route attributes that don't match the current route, I need to override it for nav items:

/* OVERRIDE default [data-route] behavior of
   hiding elements. For nav items specifically */
[data-current-route] #nav li a[data-route] {
    display: block;
}

Then, when the route matches, the background should be light gray and have a border. Because I only want the top and bottom border, I'll set the border-style such that the top and bottom border are solid and the left and right border are none.

/* Show light background and top and bottom border
   for active route and hovered menu items */
[data-current-route='contact'] #nav li a[data-route='contact'],
[data-current-route='brands'] #nav li a[data-route='brands'],
[data-current-route='events'] #nav li a[data-route='events'],
[data-current-route='about'] #nav li a[data-route='about'] {
  background: var(--light-gray);

  /* Top and bottom border */
  border: 1px var(--foreground);
  border-style: solid none;
}

router.gif

Figure 6: Final router

Collapsible menu

For mobile users, a nav bar will usually be either too small to read or be wider than the phone width. My preferred solution when the screen is too small to fit all the links side-by-side is a collapsible menu. The principle of mobile-first development says I should build a collapsible menu that turns into a nav bar when the screen is large enough rather than a nav bar that turns into a collapsible menu when the screen becomes too small. The reason for this principle is two-fold: most web traffic is mobile and mobile phones have slower rendering engines than desktops.

The collapsible menu should always be visible, so I'll set the position of the whole nav bar to fixed:

#nav {
    /* Set collapsible menu position */
    position: fixed;
    top: 1em;
    left: 2em;
}

While I'm at it, I'll also get rid of the bullets, padding and overflow, and add a border and background:

#nav {
    ...

    /* Remove bullet points */
    list-style-type: none;

    /* Hide any content from children that exceeds the parent's size */
    overflow: hidden;

    padding: 0;
    border: 1px solid var(--foreground);
    border-radius: 4px;
    background: var(--gray);
}

Each item within the collapsible menu should have some padding and cursor should be a pointer when hovering over them.

#nav li a {
    padding: 0.5em;
    cursor: pointer;
}

ul.png

Figure 7: Prettified unordered list

The checkbox

The menu can be in one of two states: collapsed or expanded. Coincidentally, this is the same number of states a checkbox can be in, and css can select based on the checked status, so the checkbox can control the visibility of other elements. The checkbox itself will be the only thing visible when the menu is collapsed (and the checkbox is unchecked), then, when checked, the other menu items should appear.

<ul id="nav">
  <!-- 'Menu' button -->
  <input type="checkbox" class="expand">

  <li><a>Contact</a></li>
  <li><a>Brands</a></li>
  <li><a>Events</a></li>
  <li><a>About</a></li>
</ul>
#nav li {
    /* Hide menu items by default */
    display: none;
}

#nav .expand:checked ~ li {
    /* Show menu items when checkbox is checked */
    display: list-item;
}

checkbox-unchecked.png

Figure 8: Unchecked checkbox

checkbox-checked.png

Figure 9: Checked checkbox

This uses the sibling css selector (~) to apply display: list-item to all li elements that are at the same level as the checkbox with the expand class when it is checked.

Prettifying the checkbox

The checkbox can be styled to look like a menu button using the after pseudoclass.

First, I'll add a custom attribute data-label to the checkbox which will be the text of the button:

<ul id="nav">
  <!-- 'Menu' button -->
  <input type="checkbox" class="expand" data-label="Menu">

  ...
</ul>

Then, I'll hide the actual checkbox:

#nav .expand {
    /* Hide menu expand checkbox */
    visibility: hidden;
}

And show the data-label attribute in the after pseudoclass:

#nav .expand:after {
    /* Show 'data-label' */
    visibility: visible;
    content: attr(data-label);
}

Because the menu is sized based on the checkbox (not the after pseudoclass), the label will bleed out of the menu. That can be fixed by making the checkbox the full width and height of the menu:

#nav .expand {
    /* Hide menu expand checkbox */
    visibility: hidden;

    /* Fill width and height, including 'after' pseudoclass */
    width: 100%;
    height: 100%;
}

The menu button should share some styles with the anchor elements, so I'll extend the #nav li a selectors to include the after pseudoclass:

#nav li a,
h3,
#nav .expand:after {
    color: var(--foreground);
    font-family: 'League Spartan', sans-serif;
}

#nav li a,
#nav .expand:after {
    padding: 0.5em;
    cursor: pointer;
}

menu-rough.png

Figure 10: Rough collapsible menu

Final touches

Now for some nitpicking. The checkbox has, by default, a smaller em unit size and a small margin around it. I want the font size to be 1em and the margin to be 0:

#nav .expand {
    ...
    margin: 0;
    font-size: 1em;
}

The after pseudoclass has a default display of inline, which precludes using text-align: center to center the label. I'll instead use display: block; in order to get the text to align correctly:

#nav .expand:after {
    ...

    /* Allow text-align to work*/
    display: block;

    text-align: center;
}

Next I'll add a background and change it based on the hover and checked states of the expand checkbox.

#nav .expand:after {
    ...
    background: var(--gray);
}

#nav .expand:checked:after,
#nav .expand:hover:after {
    background: var(--light-gray);
}

When expanded, the menu label should also have a bottom border to separate it from the other items:

#nav .expand:checked:after {
    border-bottom: 1px solid var(--foreground);
}

The list items should have a semi-transparent border to distinguish each of them on the top and bottom. The exception to this is the first and last child which touch this existing #nav border and should never have their own border. I have to over-specify the selector by adding [data-current-route] before in order to also handle the case when the route matches the current route.

#nav li a {
    /* Top and bottom border */
    border: 1px rgba(255, 255, 255, 0.1);
    border-style: solid none;
}

[data-current-route] #nav li:first-of-type a {
    border-top-style: none;
}

[data-current-route] #nav li:last-of-type a {
    border-bottom-style: none;
}

Finally, when hovering over an anchor element, the background and border should change color, so I will extend the selectors for highlighting the current route to include any anchor element which is hovered over:

[data-current-route='contact'] #nav li a[data-route='contact'],
[data-current-route='brands'] #nav li a[data-route='brands'],
[data-current-route='events'] #nav li a[data-route='events'],
[data-current-route='about'] #nav li a[data-route='about'],
#nav li a:hover {
    background: var(--light-gray);

    /* Top and bottom border */
    border: 1px var(--foreground);
    border-style: solid none;
}

collapsible-menu.gif

Figure 11: Final collapsible menu

Nav Bar

For larger screens, it makes more sense to show all menu options all at once in a nav bar. The min-width required to show all items will be different depending on each website, but for this one 480px works quite well:

@media only screen and (min-width: 480px) {

}

The menu button is no longer required and the list items should be displayed:

@media only screen and (min-width: 480px) {
    #nav .expand {
        display: none;
    }

    #nav li {
        display: list-item;
    }
}

I'll add float: left; to each list element to make the elements float next to each other as opposed to on top of one another:

@media only screen and (min-width: 480px) {
    ...
    #nav li {
        display: list-item;
        float: left;
    }
}

nav-bar-float.png

Figure 12: Float nav items to the left

In order to center the nav bar, I'll unset the 'fixed' position for #nav:

@media only screen and (min-width: 480px) {
    #nav {
        position: unset;
    }
    ...
}

Finally, I'll switch from having a top and bottom border on each anchor element to having a left and right border, and do the same for highlighting the current route in the menu. Again, the first and last child should not have a left or right border respectively, so they should be over-specified to include the case where the current route matches.

@media only screen and (min-width: 480px) {
    ...

    #nav li a {
        /* Left and right border */
        border-style: none solid;
    }

    [data-current-route='contact'] #nav li a[data-route='contact'],
    [data-current-route='brands'] #nav li a[data-route='brands'],
    [data-current-route='events'] #nav li a[data-route='events'],
    [data-current-route='about'] #nav li a[data-route='about'],
    #nav li a:hover {
        /* Left and right border */
        border-style: none solid;
    }

    [data-current-route] #nav li:first-of-type a {
        border-left-style: none;
    }

    [data-current-route] #nav li:last-child a {
        border-right-style: none;
    }
}

nav-bar.gif

Figure 13: Final nav bar

Combined final html file:

<html>
  <head>
    <title>Nav Bar Example</title>

    <!-- Load the 'League Spartan' font from google -->
    <link
      href="https://fonts.googleapis.com/css2?family=League+Spartan&display=swap"
      rel="stylesheet"
    />

    <!-- Set viewport so the webpage is not
         automatically scaled down for mobile devices -->
    <meta name="viewport" content="width=device-width, initial-scale=1" />

    <style>
      /* Set css color vars */
      :root {
          --background: #6b2e2e;
          --foreground: #fffee6;
          --gray: #524751;
          --light-gray: #8a7588;
      }

      body {
          background: var(--background);

          /* Center items and stack them on top of each other */
          display: flex;
          flex-direction: column;
          align-items: center;
      }

      #nav li a,
      h3,
      #nav .expand:after {
          color: var(--foreground);
          font-family: 'League Spartan', sans-serif;
      }

      #nav {
          /* Set collapsible menu position */
          position: fixed;
          top: 1em;
          left: 2em;

          /* Remove bullet points */
          list-style-type: none;

          /* Hide any content from children that exceeds the parent's size */
          overflow: hidden;

          padding: 0;
          border: 1px solid var(--foreground);
          border-radius: 4px;
          background: var(--gray);
      }

      #nav li {
          /* Hide menu items by default */
          display: none;
      }

      #nav .expand:checked ~ li {
          /* Show menu items when checkbox is checked */
          display: list-item;
      }

      #nav .expand {
          /* Hide menu expand checkbox */
          visibility: hidden;

          /* Fill width and height, including 'after' pseudoclass */
          width: 100%;
          height: 100%;

          margin: 0;
          font-size: 1em;
      }

      #nav .expand:after {
          /* Show 'data-label' */
          visibility: visible;
          content: attr(data-label);

          /* Allow text-align to work*/
          display: block;

          text-align: center;
          background: var(--gray);
      }

      #nav .expand:checked:after,
      #nav .expand:hover:after {
          background: var(--light-gray);
      }

      #nav .expand:checked:after {
          border-bottom: 1px solid var(--foreground);
      }

      #nav li a,
      #nav .expand:after {
          padding: 0.5em;
          cursor: pointer;
      }

      #nav li a {
          /* Top and bottom border */
          border: 1px rgba(255, 255, 255, 0.1);
          border-style: solid none;
      }

      [data-current-route] #nav li:first-of-type a {
          border-top-style: none;
      }

      [data-current-route] #nav li:last-of-type a {
          border-bottom-style: none;
      }

      /* Hide all routes that are not equal to the current-route */
      [data-current-route='contact'] [data-route]:not([data-route='contact']),
      [data-current-route='brands'] [data-route]:not([data-route='brands']),
      [data-current-route='events'] [data-route]:not([data-route='events']),
      [data-current-route='about'] [data-route]:not([data-route='about']) {
          display: none;
      }

      /* OVERRIDE default [data-route] behavior of
         hiding elements. For nav items specifically */
      [data-current-route] #nav li a[data-route] {
          display: block;
      }

      /* Show light background and top and bottom border
         for active route and hovered menu items */
      [data-current-route='contact'] #nav li a[data-route='contact'],
      [data-current-route='brands'] #nav li a[data-route='brands'],
      [data-current-route='events'] #nav li a[data-route='events'],
      [data-current-route='about'] #nav li a[data-route='about'],
      #nav li a:hover {
          background: var(--light-gray);

          /* Top and bottom border */
          border: 1px var(--foreground);
          border-style: solid none;
      }

      @media only screen and (min-width: 480px) {
          #nav {
              position: unset;
          }

          #nav .expand {
              display: none;
          }

          #nav li {
              display: list-item;
              float: left;
          }

          #nav li a {
              border-style: none solid;
          }

          [data-current-route='contact'] #nav li a[data-route='contact'],
          [data-current-route='brands'] #nav li a[data-route='brands'],
          [data-current-route='events'] #nav li a[data-route='events'],
          [data-current-route='about'] #nav li a[data-route='about'],
          #nav li a:hover {
              /* Left and right border */
              border-style: none solid;
          }

          [data-current-route] #nav li:first-of-type a {
              border-left-style: none;
          }

          [data-current-route] #nav li:last-child a {
              border-right-style: none;
          }
      }
    </style>
    <script>
      const routes = ['contact', 'brands', 'events', 'about'];
      const fallback = 'contact';

      function updateRouter() {
        let route = location.pathname.slice(1); // Remove leading slash

        // Default to fallback page if route is not recognized
        if (!routes.includes(route)) route = fallback;

        document.body.setAttribute('data-current-route', route);
      }

      function goto(route) {
         // Change url bar to new route
        history.pushState(null, '', route === fallback ? '/' : route);
        updateRouter()
      }

      document.addEventListener('DOMContentLoaded', updateRouter);
      window.onpopstate = updateRouter;
    </script>
  </head>
  <body>
    <ul id="nav">
      <!-- 'Menu' button -->
      <input type="checkbox" class="expand" data-label="Menu">

      <li><a data-route="contact" onclick="goto('contact')">Contact</a></li>
      <li><a data-route="brands" onclick="goto('brands')">Brands</a></li>
      <li><a data-route="events" onclick="goto('events')">Events</a></li>
      <li><a data-route="about" onclick="goto('about')">About</a></li>
    </ul>
    <div data-route="contact">
      <h3>Contact</h3>
    </div>
    <div data-route="brands">
      <h3>Brands</h3>
    </div>
    <div data-route="events">
      <h3>Events</h3>
    </div>
    <div data-route="about">
      <h3>About</h3>
    </div>
  </body>
</html>