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.
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.
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:
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 req
uest and res
ponse 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.