Creating a responsive menu with remix and tailwind

For my current project Patent Cockpit, I wanted to create a mobile friendly menu. The menu should be a transparent bar on wide screen. On small screens it should collapse to a "burger" menu that when clicked, shows the link items as a list together with a close button.

Since Remix tries to minimize the amount of client side Javascript, I wanted to find a solution that relies only on HTML and CSS. This can be achieved using a hidden checkbox input and the peer functionality of tailwind.

If you don't want to use an input element, you can try to use the target pseudo class on the menu with id=menu. The menu icon is then not a label but a link to #menu.

Setup

If you want to have a local copy of the code shown below, clone the repo with

git clone https://github.com/bielern/remix-tailwind-mobile-menu.git
git checkout mobile-menu

If you want to install Remix and tailwind for a local project, follow the linked installation guides.

Code Walkthrough

To fully understand, what is going on, it is best to consult the Remix documentation as Remix relies on a lot of conventions. However, to get a first glimps feel free to read ahead.

The intersting files are in the app/routes folder: index.jsx with the index/Home page, about.jsx with the "About" page and components.jsx with the menu header. The export default function Index() part in index.jsx and about.jsx tells remix that these are pages to be rendered (on the server side that is). They both import the Header menu from components.

// index.jsx; about.jsx looks similar
import { Header } from "./components";

export default function Index() {
  return (
    <div className="bg-sky-100 min-h-screen">
      <Header />
      <div className="content">
        <h1>Home to our Example</h1>
        <p>
          My home is my castle.
        </p>
      </div>
    </div>
  );
}

The important bits are in components.jsx.

import { NavLink } from "@remix-run/react"

// Removed the IconMenu and IconX from https://heroicons.com
// ...

export function Header () {
  // style the active link with a border at the bottom on wide screens
  // and with bold fonts on mobile
  function linkStyle({isActive}) {
    return isActive 
      ? "sm:border-b-2 font-semibold sm:font-base border-current" 
      : ""
  }

  return (
    <div className="py-4 px-10 bg-white/30 flex sm:flex-row flex-col">
      <input type="checkbox" className="hidden peer" id="menu"/>
      {/* This label can click the hidden input thanks to htmlFor */}
      <label htmlFor="menu"
        className="sm:hidden cursor-pointer self-end peer-checked:hidden">
        <IconMenu />
      </label>

      <div className="hidden sm:flex peer-checked:flex sm:flex-row flex-col gap-4">
        <label className="sm:hidden cursor-pointer self-end" htmlFor="menu">
          <IconX />
        </label>
        <NavLink className={linkStyle} to="/">Home</NavLink>
        <NavLink className={linkStyle} to="/about">About</NavLink>
      </div>
    </div>
  )
}

Tailwind targets by default small mobile screens. For wider screens, you need to use the breakpoint classes. sm for everything above small, md for everything above medium and so on. Thus, the sm modifier to overwrites on wider screens the standard behavior on a small mobile device.

What is going on on wide screens? All the labels are hidden on the wide screens due to the sm:hidden classes. The input is anway hidden due to the hidden class. The list with the navigation links is shown by default (sm:flex countering the hidden). So more or less, we just see the semi-transparent bar with two links on it.

Menu on a wide screen

On a wider screen, the menu appears as a semi-transparent bar at the top.

When we look at the website on small screens (CMD+CTRL+R in Safari on MacOS), we can now see the burger menu icon IconMenu. It is connected to the id=menu of the input through the htmlFor=menu attribute. So clicking it, will check/uncheck the input.

The input has the peer pseud-class on it. This way, elements below it can redener differently depending on the state of the peer. So while the div with the navigation links is hidden by default, it gets displayed, when the input is checked due to peer-checked:flex.

As the input is checked, the burger menu becomes hidden (peer-checked:hidden), while the close icon appears within the div for the nav links.

With this approach, the menu is just shown as a larger area at the top. If you prefer a kind of a layover effect of the menu, you can play around with relative on the top div and absolute min-h-screen on the div with the links.

Conclusion

It took me a while to have a nice solution for a mobile menu without relying on javascript or some hand-made CSS. You can achieve the basic behavior of a collaping mobile menu with a hidden input and simple tailwind classes as well as modifiers.

Previous
Previous

"Truncated ZIP file" error with Apache POI and akka-http

Next
Next

Git-Fu Level 2: Black Belt