tutorial // Jan 13, 2023

How to Build a Responsive Sidebar Nav with HTML, CSS, and JavaScript

How to implement a sidebar navigation that's static on desktop screens and toggleable on mobile screens.

How to Build a Responsive Sidebar Nav with HTML, CSS, and JavaScript

For this tutorial, we're going to use CheatCode's full-stack JavaScript framework, Joystick. Joystick brings together a front-end UI framework with a Node.js back-end for building apps.

To begin, we'll want to install Joystick via NPM. Make sure you're using Node.js 16+ before installing to ensure compatibility (give this tutorial a read first if you need to learn how to install Node.js or run multiple versions on your computer):

Terminal

npm i -g @joystick.js/cli

This will install Joystick globally on your computer. Once installed, next, let's create a fresh project:

Terminal

joystick create app

After a few seconds, you will see a message logged out to cd into your new project and run joystick start:

Terminal

cd app && joystick start

After this, your app should be running and we're ready to get started.

Adding Font Awesome

Although technically optional, for this tutorial, we're going to utilize an icon from the Font Awesome library. This library has free and paid options that give you access to a large library of high-quality icons in the form of either an icon font or SVG graphics. To keep things simple, we're going to rely on their icon font option which is served via CDN (content delivery network) link.

If you don't have one already, to start, head over to the Font Awesome site and set up an account. Once you're in, head over to the Kits page and then click the "New Kit +" button at the top of the page.

nkKq28ov98it96Zi/xE9ZQGyhpgXXVMxw.0
Accessing your Kit's Code on Font Awesome.

On the next page, under the "Add Your Kit's Code to a Project" section (#1), click the blue "Copy Kit Code" button to the right of the input with the <script></script> tag in it.

Once you have this copied, open up the app we just created above and locate the /index.html file at the root of the project. Inside, just above the ${css} placeholder, paste in the <script></script> tag you just copied and save the file.

/index.html

<!doctype html>
<html class="no-js" lang="en">
  <head>
    ...
    <link rel="manifest" href="/manifest.json">
    <script src="https://kit.fontawesome.com/<kitId>.js" crossorigin="anonymous"></script>
    ${css}
  </head>
  <body>
    ...
  </body>
</html>

That's it. Now we have Font Awesome installed and can put it to use later in the tutorial.

Adding some reset styles

Real quick, before we jump into the navigation implementation, to make our CSS a bit smoother, we want to add two declarations to our /index.css file (this is loaded automatically on every page by our /index.html file):

/index.css

*,
*:before,
*:after {
  box-sizing: border-box;
}

* {
  margin: 0;
  padding: 0;
}

body {
  font-family: "Helvetica Neue", "Helvetica", "Arial", sans-serif;
  font-size: 16px;
  background: #fff;
}

Here, we're adding two declarations above the existing body declaration. At the top, we're ensuring that all elements use the border-box. We want to do this because it makes sizing and positioning elements much easier. Per the MDN docs:

box-sizing tells the browser to account for any border and padding in the values you specify for an element's width and height. If you set an element's width to 100 pixels, that 100 pixels will include any border or padding you added, and the content box will shrink to absorb that extra width. This typically makes it much easier to size elements.

Here, we use the * selector to say "all elements" and also include all of the :before and :after pseudo elements.

Just below this, we use the same * wildcard selector again to say "all elements" should get a default margin and padding of 0. This ensures that any default browser styling is "reset" so it doesn't conflict with our custom styles.

Next up, to simplify things, we're going to quickly modify the /ui/pages/index/index.js file to strip its content back.

Simplifying the index page

Because our focus will be on the sidebar navigation, we don't care too much about our page content. To make our UI less jarring, real quick, let's replace the existing content in the file at /ui/pages/index/index.js with the following:

/ui/pages/index/index.js

import ui from '@joystick.js/ui';

const Index = ui.component({
  render: () => {
    return `
      <div>
        <p>Index Page</p>
      </div>
    `;
  },
});

export default Index;

This is all we need to do. The "point" is to replace all of the existing HTML, CSS, and interactivity with a plain component that just renders a string. While you don't have to do this, it will give a better context for the work we'll do next.

Implementing a sidebar navigation

Now for the fun part. To implement our navigation, we're going to utilize the existing App layout component that was created for us when we ran joystick create app above. In a Joystick app, the layout is a component designed to wrap all other components. This allows us to offer a static navigation and other elements, swapping in the current page's content.

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  render: ({ props, component }) => {
    return `
      <div>
        ${component(props.page, props)}
      </div>
    `;
  },
});

export default App;

By default, this component doesn't do anything other than render the current page it's been passed inside of a <div></div> tag. Our goal now is to build this out so that we can have a persistent sidebar navigation for all pages in our app. First, let's replace the existing HTML returned by the render() function above with the full structure we'll need for our sidebar navigation:

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  state: {
    showNavigation: false,
  },
  render: ({ props, component, state }) => {
    return `
      <div class="app">
        <header>
          <h4>App</h4>
          <i class="fas fa-bars"></i>
        </header>
        <div class="app-content">
          <aside ${state.showNavigation ? 'class="is-open"' : ''}>
            ...
          </aside>
          <main>
            ${component(props.page, props)}
          </main>
        </div>
      </div>
    `;
  },
});

export default App;

Above, we've added a few things. Focusing on the HTML being returned by the render() function, we're adding the structure for the layout we're trying to achieve. In words: we want to have a header that runs across the top of the page on the first "line" or "row," and on the second "row," a left-aligned sidebar next to a right-aligned content area. Like this:

https://cheatcode-post-assets.s3.us-east-2.amazonaws.com/FBKZhpf2iob4UCAF/sidebar-navigation-mockup.png
A mockup of the layout we're trying to achieve.

Up in the <header></header> that will run across the top of our UI, notice that we have the line <i class="fas fa-bars"></i>. This tag is rendering the bars icon from the Font Awesome library we set up earlier. Technically, this can be any icon (or other HTML) that you'd like. In a bit, we'll use this as the "trigger" to toggle the visibility of our sidebar navigation on mobile devices.

Down toward the bottom of our HTML, we're making a call to component(props.page, props). Here, we're using Joystick's component render method (the name Joystick uses for the methods/functions passed to the render() function which helps do things like render other components, loop over lists, and render HTML conditionally) to take in the page prop that Joystick automatically passes to us containing the current page we want to render into the layout. To make sense of that, if we quickly look in the index.server.js file, we'll see some code like this:

/index.server.js

import node from "@joystick.js/node";
import api from "./api";

node.app({
  api,
  routes: {
    "/": (req, res) => {
      res.render("ui/pages/index/index.js", {
        layout: "ui/layouts/app/index.js",
      });
    },
    "*": (req, res) => { ... },
});

On the server, Joystick takes in an object of routes which are matched against the current URL being requested by a user. When a route matches the current URL the user has typed into their browser, the function assigned to it (here the / route matches the "index" route for our app) is called, passing in the inbound request and response objects. Here, res.render() is a function Joystick automatically adds to the res object passed to our matched route.

When that function is called, Joystick takes the string passed as the first argument (it assumes this is a page component in the /ui/pages folder) and attempts to render it. In the second argument—the options object for res.render()—if the layout option is set to the path of a layout component in /ui/layouts, Joystick will render that layout component and pass the contents of the page passed as the first argument to res.render() as props.page in the layout component.

Above, then, for our / index route, when we say component(props.page, props) inside of our layout component, we're passing the contents of /ui/pages/index/index.js into /ui/layouts/app/index.js as props.page.

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  state: {
    showNavigation: false,
  },
  render: ({ props, component, state }) => {
    return `
      <div class="app">
        <header>
          <h4>App</h4>
          <i class="fas fa-bars"></i>
        </header>
        <div class="app-content">
          <aside ${state.showNavigation ? 'class="is-open"' : ''}>
            ...
          </aside>
          <main>
            ${component(props.page, props)}
          </main>
        </div>
      </div>
    `;
  },
});

export default App;

Back in our layout component, next, we want to look at our usage of state.showNavigation. Here, we're pulling the showNavigation value from our component's state to conditionally add a class name is-open to the<aside></aside> tag which represents our sidebar navigation. Here, state is a property on the component instance object that's passed to the render() function on our component.

Up at the top of our component, we've added a state object, setting showNavigation to false. This object represents the default "state" of our component. Down in our render() function, whenever our component's state value changes, the render() function is called, passing in the updated state and re-rendering our HTML.

Here, then, when the value of state.showNavigation changes to true, we expect the is-open class to be added to our <aside></aside> (and vice versa if it's toggled in the opposite direction). Let's add in that toggle functionality now:

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  state: {
    showNavigation: false,
  },
  events: {
    'click .fa-bars': (event = {}, component = {}) => {
      component.setState({ showNavigation: !component.state.showNavigation });
    },
  },
  render: ({ props, component, state }) => {
    return `
      <div class="app">
        <header>
          <h4>App</h4>
          <i class="fas fa-bars"></i>
        </header>
        <div class="app-content">
          <aside ${state.showNavigation ? 'class="is-open"' : ''}>
            ...
          </aside>
          <main>
            ${component(props.page, props)}
          </main>
        </div>
      </div>
    `;
  },
});

export default App;

Above, we've added a new property to our layout component's options events, set to an object. On that object, we can define the event listeners and handlers for our component. Here, we've added an event listener for any click event on elements with the .fa-bars selector. The idea here is that when that event is detected, the function we've assigned to 'click .fa-bars' on the events object here will be called.

Inside of that function, we take in two arguments: event (the raw DOM event that fired) and component (the current instance for our layout component).

Here, we call to the component.setState() function to update the state.showNavigation property we talked about above. Here, we pass an object of state values that we'd like to update to .setState() (here, just showNavigation) along with its new value. In this case, we want to set showNavigation to the inverse of its current value. To do it, we just prefix a ! (commonly referred to as a "bang") to component.state.showNavigation, or, the current value of showNavigation on state.

With that, now we're ready to make everything "look" right. To do it, we're going to add an additional css property to our layout component, in between the state and events objects.

/ui/layouts/app/index.js

import ui from "@joystick.js/ui";

const App = ui.component({
  state: {
    showNavigation: false,
  },
  css: `
    .app header {
      display: flex;
      align-items: center;
      padding: 20px;
      border-bottom: 1px solid #eee;
    }

    .app header .fa-bars {
      margin-left: auto;
      font-size: 20px;
      cursor: pointer;
    }

    .app aside {
      background: #fff;
      position: fixed;
      top: 0;
      left: 0;
      bottom: 0;
      border-right: 1px solid #eee;
      padding: 30px;
      width: 250px;
      transform: translateX(-251px);
      transition: all 0.3s ease-in-out;
      box-shadow: 1px 0px 2px 2px rgba(0, 0, 0, 0.02);
    }

    .app aside.is-open {
      display: block;
      transform: translateX(0px);
    }

    .app aside nav {
      margin-bottom: 30px;
    }

    .app aside nav h5 {
      font-size: 15px;
      font-weight: bold;
      color: #333;
      margin-bottom: 15px;
    }
    
    .app aside nav ul {
      list-style: none;
    }

    .app aside nav ul li a {
      color: #888;
      text-decoration: none;
      font-size: 15px;
    }

    .app aside nav ul li a:hover {
      color: #444;
    }

    .app aside nav ul li:not(:last-child) {
      margin-bottom: 10px;
    }

    main {
      padding: 20px;
    }

    @media screen and (min-width: 1200px) {
      .app-content {
        display: flex;
      }

      .app header .fa-bars {
        display: none;
      }

      .app aside {
        display: block;
        transform: translateX(0px) !important;
        position: static !important;
        height: calc(100vh - 61px);
      }

      main {
        padding: 30px;
      }
    }
  `,
  events: { ... },
  render: ({ props, component, state }) => {
    return `
      <div class="app">
        <header>
          <h4>App</h4>
          <i class="fas fa-bars"></i>
        </header>
        <div class="app-content">
          <aside ${state.showNavigation ? 'class="is-open"' : ''}>
            ...
          </aside>
          <main>
            ${component(props.page, props)}
          </main>
        </div>
      </div>
    `;
  },
});

export default App;

This is the hard part of our work. Let's step through each of the declarations here and explain what they do in plain language:

.app header {
  display: flex;
  align-items: center;
  padding: 20px;
  border-bottom: 1px solid #eee;
}

First, we want to style the <header></header> element just inside of our main .app <div></div>. The styles here set the display to flex to trigger a Flexbox layout, which by default will force all elements inside of <header></header> to appear on a single "row." The align-items set to center here is a Flexbox-related rule that says "align all children in this element to be aligned vertically to the center." We do this here because we want our "bars" icon and "App" title to appear visually centered. Finally, to give us some breathing room we add a padding of 20px creating a whitespace around our elements and then add a 1px border set to #eee or "light gray."

.app header .fa-bars {
  margin-left: auto;
  font-size: 20px;
  cursor: pointer;
}

Next, we target our "bars" icon, here setting margin-left to auto forcing it over to the right. This works because in a parent with display: flex, a left-margin set to auto on a child automatically fills the left-margin of the specified element with 100% of the space available between that element and its nearest sibling to the left. Next, we beef up the font-size to 20px to make it easier to click and then add a cursor: pointer so that when hovered on a desktop device, the mouse cursor changes from the default black arrow to a little pointing glove to signify that the element is "clickable."

.app aside {
  background: #fff;
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  border-right: 1px solid #eee;
  padding: 30px;
  width: 250px;
  transform: translateX(-251px);
  transition: all 0.3s ease-in-out;
  box-shadow: 1px 0px 2px 2px rgba(0, 0, 0, 0.02);
}

Now for the big one, here, we're styling up the <aside></aside> that represents our sidebar. Because we're writing our styles "mobile first" (meaning the base styles apply to mobile devices and we change them using media queries for larger screens), we want to account for our navigation not always being on screen. To do it, here, we begin by setting its position to fixed which "rips it out" of the flow of all elements and positions it fixed to the browser window. To guide this positioning, we specify a top, left, and bottom, all set to 0 to say "fix this element to the top, left, and bottom of the window with no offset/space from the window."

Next, we add a border-right to create a visual separation between our sidebar and the rest of the page. After this, we add a padding of 30px to give the content in the sidebar some breathing room and then set a fixed width on it to 250px (this is an approximation which can be adjusted as necessary for your own app's navigation).

The cool part here is the transform property being set to translateX(-251px). This is telling the <aside></aside> to move -251px on the x-axis of our window, or, 251 pixels to the left (off screen). We use 251px here because that accounts for the width of our sidebar as well as its 1px border on the right.

Next, we add a transition property to apply a subtle animation that lasts 300ms whenever our element's CSS changes (we'll see how this factors in next). Finally, to give the sidebar the appearance of "floating," we give it a faint box-shadow effect that says "offset the shadow 1px on the x-axis to the right, 0px on the y-axis, and then blur it by 2px and make it spread 2px." This creates a faint, feathery shadow that looks more organic.

.app aside.is-open {
  transform: translateX(0px);
}

To handle the visibility of our sidebar (remember, the styles above just moved it off screen), we add .app aside.is-open to say "set these styles when our <aside></aside> has the class is-open. Remember that earlier, we toggled this class based on the current value of state.showNavigation. Here, when showNavigation is true, the .is-open class is added and we set the transform property to translateX(0px) saying "don't move the element on the x-axis at all."

Remember: above, we set this to -251px by default, so when we add .is-open here, we're moving our sidebar "back into view." This is where the transition comes in. When this style is set, the 300ms ease-in-out animation will be applied, giving the sidebar a "fly in" effect.

That's the big important stuff for mobile. We're going to skip over the styling for the elements inside of the <aside></aside> as these are just for example. Real quick, though, we do want to call out the styles for the <main></main> element.

main {
  padding: 20px;
}

Here, we're setting a padding of 20px to offset the content of our page from the window and give it some breathing room.

Now, for the last part, let's take a look at how we'll adjust all of the styles on a desktop so our sidebar remains fixed to the left on screens that have the room for it.

@media screen and (min-width: 1200px) {
  .app-content {
    display: flex;
  }

  .app header .fa-bars {
    display: none;
  }

  .app aside {
    transform: translateX(0px) !important;
    position: static !important;
    height: calc(100vh - 61px);
  }

  main {
    padding: 30px;
  }
}

Above, we're using what's known as a media query: a special chunk of CSS that lets us apply CSS conditionally, per some rules. Here, we're saying "apply the following styles when the @media is a screen (this could also be print which applies styles when a user attempts to print the page) and the minimum window width is 1200px)." This means that these styles will not apply if the window is smaller than 1200px.

Inside, we tweak our elements to look correct on a desktop device. First, we set .app-content's display property to flex to make our sidebar and <main></main> align horizontally on the same row. Next, we make sure to hide the .fa-bars icon with display: none as it won't be utilized on a desktop screen.

Second to last, we override the styles for our sidebar on mobile, forcing transform to be translateX(0px) (meaning don't move it on the x-axis), adding !important on the end and forcing position to static (meaning it just flows inline with its siblings like it does by default), adding !important to the end.

Here, !important tells the browser that these styles should override any other styles of this property for this element. We use this because we want to ensure a toggled or untoggled sidebar will display no matter what on desktop. Lastly here, because we're no longer fixing our sidebar to the browser, we set its height to be 100vh which is 100% of the vertical window height. To prevent an overflow, we pass this to a calc() and say "subtract 61px from 100vh where 61px is the height of our header bar up top.

Last but not least, we set the padding on our <main></main> to 30px to give it a little more breathing room on desktop.

That's it! We now have a fully responsive sidebar navigation that works across all devices.

Wrapping up

In this tutorial, we learned how to build a responsive sidebar navigation for our app. We learned how to structure the HTML for a layout so that it could accommodate any screen size, as well as how to conditionally set a CSS class using DOM events and Joystick's component state. Finally, we learned how to style up our sidebar so that it could adapt its appearance based on window width using media queries.

Written By
Ryan Glover

Ryan Glover

CEO/CTO @ CheatCode