No Time Dad

A blog about web development written by a busy dad

Responsive Navbar with Tailwind and React

I’ve made a few responsive navbars so far, but none that use Tailwindcss and React. I also tend to ignore the mobile menu and just say “use your imagination”, so I thought it would be nice to actually implement the mobile menu for once.

I’ve mentioned it previously, but mobile menus are trickier than they might appear. And this one isn’t fantastic. It does work, but it ignore some best practice for accessibility like click away and focus capture. It’s usually a good idea to use libraries like Tailwind’s headlessui or popper for menus like this. They’re better at handling click events and accessibility than I am.

The mobile menu for this example will be a list of nav links that pushes the content down. My only real requirment for the menu is that it’s shown when the menu clicked is clicked and that it’s hidden when the menu is clicked a second time or the screen size hits Tailwind’s md: breakpoint.

You view the demo and full code here.

Building the navbar

This navbar will be built using React. Mainly because I like using it, but also because I need to track if the mobile menu is open or not. I’ll be using React’s useState hook to track and update whether the mobile menu is open or not.

I’ll start with a parent container div and a Navbar component. I’ll then add the mobile menu under the navbar above the content. The menuOpen state value will determine if the MobileMenu component should be shown or not. The basic structure of my main component (Index) is shown below.

I used Tailwind’s bg-gradient utility classe in the parent div to give the navbar and the mobile menu the same fancy gradient background. I also pass the menuOpen and setMenuOpen props down the Navbar component, mostly because that is where the button to toggle the mobile menu will be.

import React, { useState } from 'react';

export const ResponsiveNavBar = () => {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div className="bg-gradient-to-r from-blue-200 to-blue-100">
      <Navbar menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
      {menuOpen &&
        <MobileMenu>
          {navLinks}
        </MobileMenu>}
    </div>
  );
};
Navbar component

The Navbar component is made up of parent div that’s a flexbox container with multiple child elements. Only two elements will be shown at a time on the navbar. The first is the div that contains the svg icon and app name. The second is the button to toggle the mobile menu or the nav links, depending on the current screen size. Both of these elements contain child elements, but for alignment purposes it’s easier to think of them as just two elements and ignore the child elements.

Since I only have two elements in the navbar, I can use Tailwind’s justify-between to ensure that each element is pushed to either end of the nav bar. If I had a third element on the navbar there would be one element on the left, one element in the center, and one element on the left in the navbar.

When the screen is less than 768px wide (Tailwind’s md breakpoint), the button to toggle the menu should be shown. At 768px or larger, it should be hidden. This is taken care of by adding md:hidden to the button elements.

When the screen is 768px or more wide, the nav links should be shown and the mobile menu button should be hidden. Since Tailwind is mobile first, I’ll add hidden to the nav and md:block to the nav element. This logic has tripped me up in the past, but it basically says display: none (hide) for small screens and display: block (show) for larger screens.

const Navbar = ({ menuOpen, setMenuOpen }) => (
  <div className="flex items-center justify-between p-4">
    {/* App header */}
    <div className="flex items-center">
      <FireSvg />
      <a href="#home" className="text-xl font-bold no-underline text-gray-800 hover:text-gray-600">Bored.io</a>
    </div>
    {/* Links shown in a row on larger screens */}
    <nav className="hidden md:block space-x-6">
      {navLinks}
    </nav>
    {/* Button to toggle mobile menu shown on smaller screens */}
    <button type="button" aria-label="Toggle mobile menu" onClick={() => setMenuOpen(!menuOpen)} className="rounded md:hidden focus:outline-none focus:ring focus:ring-gray-500 focus:ring-opacity-50"><MenuAlt4Svg menuOpen={menuOpen} /></button>
  </div>
);

// Page names that can be shared between mobile menu and navbar
const pages = ['Products', 'Pricing', 'Login'];
const navLinks = pages.map(page => <a className="no-underline text-gray-800 font-semibold hover:text-gray-600" href={`#${page}`}>{page}</a>);

const FireSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" viewBox="0 0 20 20" fill="currentColor">
    <path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" />
  </svg>
);

const MenuAlt4Svg = ({ menuOpen }) => (
  // Added a fun transition to the icon when clicked
  <svg xmlns="http://www.w3.org/2000/svg" className={`transition duration-100 ease h-8 w-8 ${menuOpen ? 'transform rotate-90' : ''}`} viewBox="0 0 20 20" fill="currentColor">
    <path fillRule="evenodd" d="M3 7a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 13a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
  </svg>
);
MobileMenu component

The MobileMenu component uses flexbox to align the content in a row. It also has some spacing added between the links via Tailwind’s space-y-3, as well as some padding around the links via p-4.

An important thing to note with this element is the md:hidden on the nav element. Without this utility class it’s possible that the mobile menu could be shown on larger screens if the user somehow clicked to open the menu then increased the size of their screens.

The children prop is just for convenience to pass the shared nav links. The links could be hard-coded, but I couldn’t think of a scenario where I wouldn’t want the same links in the mobile menu.

const MobileMenu = ({ children }) => (
  // Important to include md:hidden to prevent persistent open on large screens
  <nav className="p-4 flex flex-col space-y-3 md:hidden">
    {children}
  </nav>
);

Conclusion

I have mixed feelings about this one. It does work, is easy to implement, and looks good but I do think it would be better served by using one the menu libraries mentioned at the start. There is definitely room for improvement.

Demo & final code

Below is a demo of the component, as well as the final code. Try re-sizing your browser window to see how the navbar responds.

import React, { useState } from 'react';

export const ResponsiveNavBar = () => {
  const [menuOpen, setMenuOpen] = useState(false);
  return (
    <div className="bg-gradient-to-r from-blue-200 to-blue-100">
      <Navbar menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
      {menuOpen &&
        <MobileMenu>
          {navLinks}
        </MobileMenu>}
    </div>
  );
};

const pages = ['Products', 'Pricing', 'Login'];
const navLinks = pages.map(page => <a key={page} className="no-underline text-gray-800 font-semibold hover:text-gray-600" href={`#${page}`}>{page}</a>);

const Navbar = ({ menuOpen, setMenuOpen }) => (
  <div className="flex items-center justify-between p-4">
    <div className="flex items-center">
      <FireSvg />
      <a href="#home" className="text-xl font-bold no-underline text-gray-800 hover:text-gray-600">Bored.io</a>
    </div>
    <nav className="hidden md:block space-x-6">
      {navLinks}
    </nav>
    <button type="button" aria-label="Toggle mobile menu" onClick={() => setMenuOpen(!menuOpen)} className="rounded md:hidden focus:outline-none focus:ring focus:ring-gray-500 focus:ring-opacity-50"><MenuAlt4Svg menuOpen={menuOpen} /></button>
  </div>
);

const MobileMenu = ({ children }) => (
  <nav className="p-4 flex flex-col space-y-3 md:hidden">
    {children}
  </nav>
);


const FireSvg = () => (
  <svg xmlns="http://www.w3.org/2000/svg" className="h-10 w-10" viewBox="0 0 20 20" fill="currentColor">
    <path fillRule="evenodd" d="M12.395 2.553a1 1 0 00-1.45-.385c-.345.23-.614.558-.822.88-.214.33-.403.713-.57 1.116-.334.804-.614 1.768-.84 2.734a31.365 31.365 0 00-.613 3.58 2.64 2.64 0 01-.945-1.067c-.328-.68-.398-1.534-.398-2.654A1 1 0 005.05 6.05 6.981 6.981 0 003 11a7 7 0 1011.95-4.95c-.592-.591-.98-.985-1.348-1.467-.363-.476-.724-1.063-1.207-2.03zM12.12 15.12A3 3 0 017 13s.879.5 2.5.5c0-1 .5-4 1.25-4.5.5 1 .786 1.293 1.371 1.879A2.99 2.99 0 0113 13a2.99 2.99 0 01-.879 2.121z" clipRule="evenodd" />
  </svg>
);

const MenuAlt4Svg = ({ menuOpen }) => (
  <svg xmlns="http://www.w3.org/2000/svg" className={`transition duration-100 ease h-8 w-8 ${menuOpen ? 'transform rotate-90' : ''}`} viewBox="0 0 20 20" fill="currentColor">
    <path fillRule="evenodd" d="M3 7a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 13a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
  </svg>
);