No Time Dad

A blog about web development written by a busy dad

Sidenav Menu Example Using Tailwind and React

Building a decent sidenav almost always involves flexbox, and this example is no exception. Looking over the code, almost every component that makes up the sidenav uses flexbox. All the way down to the individual navigation items.

Really learning how to use flexbox has been a revelation for me. It’s honestly made me more confident in my layouts and how they behave on various screen sizes.

Like anything else, getting better at flexbox comes down to practice. Which leads me to the sidenav example described in this post. I wanted more practice with flexbox, and I also wanted more practice with Tailwind. I have made similar sidenavs in the past, but not with Tailwind.

I’m still deciding if Tailwind is right for me, and the best way for me to figure that out is to build things with it. Also, I really just enjoy building things. I don’t care if I ever use what I’ve built, it’s the knowledge and experience that I’m after.

Building the sidenav

Below is a screenshot of what I’ll be making. The working demo can be viewed here. I used React for this example, but I won’t be going too deep into the details of it. This isn’t meant to be a React tutorial. I like using React to break up large or complicated elements into smaller components. This helps me build mental models of how things work.

The main thing to know about my usage of React here is that I use the useState hook to track the active page, and I pass the setActivePage method down to the child components to update the state. I did this to update the title on the Content component, but I could’ve let a router help me with this instead.

sidenav_tailwind

Index component

The page itself will utilize css grid for the layout. I’ll extend Tailwind slightly to get the custom grid-template-columns values I need to define the sidenav and the content. The custom value is needed because I want the width of the sidenav to be determined by its contents. Tailwind, by default, only allows me to use fractal units which take all available space. Luckily Tailwind makes it easy to change.

// tailwind.config.js
...
theme: {
  extend: {
    gridTemplateColumns: {
      'custom-sidenav-layout': 'auto 1fr',
    }
    ...
  },
},
...

I’ll have a parent div that initializes the two column grid, as well as one child div for the sidenav and another child div for the content. This parent component is also where I’ll track the state of the active page. It’s tempting to initialize the state in the sidenav component instead given the fact that the active page state value is primarily used by it, but I wanted the content section to also know about the active page so it can display the value.

I think it’s a better user experience to display the active page on the content page, but that might just be my personal opinion.

In addition to initializing the grid on this element, I’ll also set the height to 100% of the view height via h-screen to ensure that the sidenav and content sections span the entire height of the window. There are likely many different ways to accomplish this, but I find this way the quickest and easiest.

const Index = () => {
  const [activePage, setActivePage] = useState("Dashboard");
  return (
    <div className="h-screen grid grid-cols-custom-sidenav-layout">
      <Sidenav activePage={activePage} setActivePage={setActivePage} />
      <Content title={activePage} />
    </div>
  );
}
Sidenav component

The sidenav itself is made up of three sections. A header, a menu, and a footer. The header is where an icon or logo goes, along with the name of the application. After that, there is a menu with links to various parts of the application that will be rendered in the content section of the page. Lastly, the footer is where I might put settings or logout buttons.

The sidenav will be my first usage of flexbox. Specifically, I’ll be using Tailwind’s flex with flex-col, forcing the flex-direction to column. This is an interesting point because most of the sidenav items are naturally going to be in a column anyways, so at first glance it’s redundant to use flexbox in this way here. But, I want a footer section in the sidenav, and flexbox makes it easy to push an element to the button via margin-top: auto; on the element inside the parent flexbox container.

const Sidenav = ({ activePage, setActivePage }) => (
  <div className="flex flex-col bg-green-900 text-green-50 px-6 py-4">
    <SidenavHeader />
    <SidenavMenu activePage={activePage} setActivePage={setActivePage} />
    <SidenavFooter />
  </div>
);
Sidenav header component

The sidenav header uses flexbox to center align the large icon with the large text. I did this by using Tailwind’s flex with items-center, which is equivalent to display: flex; and align-items: center; in vanilla css. The text itself should usually be a link to whatever the “homepage” is the application. I made the text a little fancier by increasing the size, color, and hover effect. I also had to remove the default link styles via Tailwind’s no-underline, which is the same as text-decoration: none;. I’ll do this in a few other places throughout the sidenav and content sections too.

const SidenavHeader = () => (
  <div className="flex items-center ml-1 pb-8">
    <FireSvg />
    <a href="#home" className="text-xl font-bold pl-1 no-underline text-green-50 hover:text-green-100">bored.io</a>
  </div>
);
Sidenav menu component

The sidenav menu is where the links to various pages in the application go. The nav links all sit inside of a nav tag. This tag could easily also be a div, but I think using the nav tag gives a clear indication of what it’s contents are and their intention.

Tailwind has a really clever utility class that I used here to add spacing between elements. The space-y-2 class I used adds vertical spacing between the links.

const SidenavMenu = ({ activePage, setActivePage }) => (
  <nav className="space-y-2">
    <NavItem activePage={activePage} link="#dashboard" svgIcon={<ChartPieSvg />} title="Dashboard" setActivePage={setActivePage} />
    <NavItem activePage={activePage} link="#users" svgIcon={<UsersSvg />} title="Users" setActivePage={setActivePage} />
    <NavItem activePage={activePage} link="#users" svgIcon={<ChatAltSvg />} title="Messages" setActivePage={setActivePage} />
  </nav>
);

Nav item component

The nav links are the cornerstone of the sidenav. They’re where the click handlers are added to change the active link styles, and they’re also where the content would be changed. Flexbox is again used here to help with alignment between the icon and the text.

I needed a way to visually show the user which nav item is currently “active”, so I use a ternary operator to dynamically change the background color of a given nav item based on the activePage prop passed to it.

An alternative approach I could’ve used for the NavItem is to use props.children instead of passing the title text and svgIcon component as props. The reason I chose not to do it this way is because I think determining whether a nav item is active or not is better encapsulted in the NavItem component itself instead of in the parent component SidenavMenu. Additionally, I can’t add a click handler to a React component, NavItem in this case, to set the active page. I can only add click handlers to native elements like a, div, span, etc.

const NavItem = ({ activePage, link, svgIcon, title, setActivePage }) => (
  <a onClick={() => setActivePage(title)} href={link} className={`flex items-center no-underline text-green-50 hover:text-green-100 p-3 rounded-md ${activePage === title ? 'bg-green-700' : ''}`}>
    {svgIcon}
    <div className="font-bold pl-3">{title}</div>
  </a>
);
Sidenav footer component

The last component in the sidenav is the footer. Similar to the others, this component also uses flexbox for alignment via Tailwind’s flex utility class. As mentioned at the start, the magic that pushes the footer to the bottom of the sidenav is margin-top: auto; done through Tailwind’s mt-auto class.

Usually, I’d put a logout or settings button in a sidenav footer. I opted for settings here.

const SidenavFooter = () => (
  <a href="#settings" className="flex items-center mt-auto px-3 no-underline text-green-50 opacity-70 hover:opacity-100">
    <CogSvg />
    <div className="pl-2">Settings</div>
  </a>
);

Content component

With the sidenav now complete I can take a look at the content section of the page. I could really just stop at the sidenav for this example, but I thought it’d be fun to add some interactivity between the sidenav and content.

The Content component takes the activePage from the sidenav and displays it at the top. I then added a border underneath of it to break up the top from the bottom. Flexbox is used again for this component, but mostly to ensure that the content takes up the available space using Tailwind’s flex-1 which is equivalent to flex: 1 1 0%;. The main content portion of the page is a placeholder.

const Content = ({ activePage }) => (
  <div className="flex flex-col">
    <div className="text-xl font-bold text-gray-600 border-b-2 border-green-200 pt-6 pb-2 px-6">{activePage}</div>
    <div className="flex-1 my-6 mx-6 border-8 border-gray-200 rounded-xl border-dotted"></div>
  </div>
);

Conclusion and final code

Tailwind made this easier but not without my prior css knowledge. I know I keep stressing this point, but I do think it’s important. A strong knowledge of css is beneficial when using Tailwind.

Alignment in the sidenav was a struggle. I really wanted the header icon to line up over the nav item icons. I think I mostly achieved this, but it definitely took a lot of fiddling with the padding to get it right…and it still might be slightly off.

As always, there’s room for improvement with this sidenav. The main issue being that it’s not responsive. Another issue is that the colors aren’t great. I think the contrast between light and dark could be better. There are probably more issues, but it’s another good starting point to build on, and it was a fun exercise.

The full working demo can be viewed here. I used svg icons from Heroicons for this example.

Final code
import React, { useState } from 'react';

const Index = () => {
  const [activePage, setActivePage] = useState("Dashboard");
  return (
    <div className="h-screen grid grid-cols-custom-sidenav-layout">
      <Sidenav activePage={activePage} setActivePage={setActivePage} />
      <Content activePage={activePage} />
    </div>
  );
}

const Sidenav = ({ activePage, setActivePage }) => (
  <div className="flex flex-col bg-green-900 text-green-50 px-6 py-4">
    <SidenavHeader />
    <SidenavMenu activePage={activePage} setActivePage={setActivePage} />
    <SidenavFooter />
  </div>
);

const SidenavHeader = () => (
  <div className="flex items-center ml-1 pb-8">
    <FireSvg />
    <a href="#home" className="text-xl font-bold pl-1 no-underline text-green-50 hover:text-green-100">bored.io</a>
  </div>
);

const SidenavMenu = ({ activePage, setActivePage }) => (
  <nav className="space-y-2">
    <NavItem activePage={activePage} link="#dashboard" svgIcon={<ChartPieSvg />} title="Dashboard" setActivePage={setActivePage} />
    <NavItem activePage={activePage} link="#users" svgIcon={<UsersSvg />} title="Users" setActivePage={setActivePage} />
    <NavItem activePage={activePage} link="#users" svgIcon={<ChatAltSvg />} title="Messages" setActivePage={setActivePage} />
  </nav>
);

const NavItem = ({ activePage, link, svgIcon, title, setActivePage }) => (
  <a onClick={() => setActivePage(title)} href={link} className={`flex items-center no-underline text-green-50 hover:text-green-100 p-3 rounded-md ${activePage === title ? 'bg-green-700' : ''}`}>
    {svgIcon}
    <div className="font-bold pl-3">{title}</div>
  </a>
);

const SidenavFooter = () => (
  <a href="#settings" className="flex items-center mt-auto px-3 no-underline text-green-50 opacity-70 hover:opacity-100">
    <CogSvg />
    <div className="pl-2">Settings</div>
  </a>
);

const Content = ({ activePage }) => (
  <div className="flex flex-col">
    <div className="text-xl font-bold text-gray-600 border-b-2 border-green-200 pt-6 pb-2 px-6">{activePage}</div>
    <div className="flex-1 my-6 mx-6 border-8 border-gray-200 rounded-xl border-dotted"></div>
  </div>
);

const FireSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
    <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9.879 16.121A3 3 0 1012.015 11L11 14H9c0 .768.293 1.536.879 2.121z" />
  </svg>
);

const ChartPieSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
    <path d="M2 10a8 8 0 018-8v8h8a8 8 0 11-16 0z" />
    <path d="M12 2.252A8.014 8.014 0 0117.748 8H12V2.252z" />
  </svg>
);

const UsersSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
    <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
  </svg>
);

const ChatAltSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" viewBox="0 0 20 20" fill="currentColor">
    <path d="M2 5a2 2 0 012-2h7a2 2 0 012 2v4a2 2 0 01-2 2H9l-3 3v-3H4a2 2 0 01-2-2V5z" />
    <path d="M15 7v2a4 4 0 01-4 4H9.828l-1.766 1.767c.28.149.599.233.938.233h2l3 3v-3h2a2 2 0 002-2V9a2 2 0 00-2-2h-1z" />
  </svg>
);

const CogSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
    <path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd" />
  </svg>
);


export default Index;