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>
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.
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
.
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; }
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.
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; }
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; }
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; }
Figure 8: Unchecked checkbox
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; }
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; }
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; } }
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; } }
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>