DEV Community

Cover image for Build a Hoverable Dropdown Menu with Tailwind CSS and Alpine.js
Cruip
Cruip

Posted on • Originally published at cruip.com

Build a Hoverable Dropdown Menu with Tailwind CSS and Alpine.js

Live Demo/ Download

A dropdown menu is a popular web element that contains all the second-level information for a specific category. At Cruip, we have developed several of them in our templates (e.g., check out this simple website template or this SaaS website template). So we decided to write this tutorial to show you how we approach the development of this particular component.

You’ve probably come across many tutorials on creating a dropdown menu with Tailwind CSS, but most of them focus on submenus that open when you click an element. In this guide, we’ll switch things up and show you how to build a fly-out menu that opens on hover, which is fully accessible. That means keyboard users can open, close, and navigate within this menu seamlessly.

We will use Tailwind CSS and the awesome Alpine.js to handle transitions and interactions and make sure our menu is accessible to everyone. We took inspiration from the W3C’s approach to toggling menus with a button right next to the link.

Create the markup

We’ll create a very simple structure, made of a wrapping <nav> element that holds an unordered list of items, like this:

  <nav>
      <ul>
          <li>
              <a href="#">First level item</a>
          </li>
          <li>
              <a href="#">First level item</a>
          </li>
          <li>
              <a href="#">First level item</a>
              <button>Show submenu for "First level item"</button>
              <ul>
                  <li>
                      <a href="#">Second level item</a>
                  </li>
                  <li>
                      <a href="#">Second level item</a>
                  </li>
              </ul>
          </li>
      </ul>
  </nav>
Enter fullscreen mode Exit fullscreen mode

The example above is a basic version of our navigation menu. To make it look like the demo, we’ll add some Tailwind CSS classes, like so:

  <nav class="flex justify-center">

      <ul class="flex flex-wrap items-center font-medium text-sm">
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Prospects</a>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">History</a>
          </li>
          <li class="p-4 lg:px-8 relative flex items-center space-x-1">
              <a class="text-slate-800 hover:text-slate-900" href="#0">Flyout Menu</a>
              <button class="shrink-0 p-1">
                  <span class="sr-only">Show submenu for "Flyout Menu"</span>
                  <svg class="w-3 h-3 fill-slate-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                      <path d="M10 2.586 11.414 4 6 9.414.586 4 2 2.586l4 4z" />
                  </svg>
              </button>
              <!-- 2nd level menu -->
              <ul class="origin-top-right absolute top-full left-1/2 -translate-x-1/2 min-w-[240px] bg-white border border-slate-200 p-2 rounded-lg shadow-xl">
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="9" height="12">
                                  <path d="M8.724.053A.5.5 0 0 0 8.2.1L4.333 3H1.5A1.5 1.5 0 0 0 0 4.5v3A1.5 1.5 0 0 0 1.5 9h2.833L8.2 11.9a.5.5 0 0 0 .8-.4V.5a.5.5 0 0 0-.276-.447Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Priority Ratings</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                                  <path d="M11.953 4.29a.5.5 0 0 0-.454-.292H6.14L6.984.62A.5.5 0 0 0 6.12.173l-6 7a.5.5 0 0 0 .379.825h5.359l-.844 3.38a.5.5 0 0 0 .864.445l6-7a.5.5 0 0 0 .075-.534Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Insights</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                                  <path d="M6 0a6 6 0 1 0 0 12A6 6 0 0 0 6 0ZM2 6a4 4 0 0 1 4-4v8a4 4 0 0 1-4-4Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Item Mirror</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="11" height="11">
                                  <path d="M10.866.134a.458.458 0 0 0-.481-.106L.302 3.695a.458.458 0 0 0-.014.856l4.4 1.76 1.76 4.4c.07.175.24.29.427.29h.007a.458.458 0 0 0 .424-.302L10.973.615a.458.458 0 0 0-.107-.48Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Support Center</span>
                      </a>
                  </li>
              </ul>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Contacts</a>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Numbers</a>
          </li>
      </ul>

  </nav>
Enter fullscreen mode Exit fullscreen mode

We’ve kept the same basic setup but added several classes to style the various elements. Additionally, we added an SVG for the icon indicating the presence of a submenu. Now that we have the menu in place, let’s move on to handling interaction with Alpine.js.

Manage submenu transitions

You know, we could definitely manage the opening and closing transitions of the submenu using plain CSS. But, since we will need to use Alpine.js to handle accessibility, we’ve intentionally opted for using its transition system for animations.

In the example we showed earlier, the submenu is open by default, and there is no interaction with the toggle. To make the menu behave as we want, we need to add some logic.

Let’s start by adding the x-data directive to the li element that contains the submenu and define the transitions we want to use to open and close the menu on the ul element that constitutes the submenu itself:

  <li
      class="p-4 lg:px-8 relative flex items-center space-x-1"
      x-data="{ open: false }"
      @mouseenter="open = true"
      @mouseleave="open = false"               
  >
      <a class="text-slate-800 hover:text-slate-900" href="#0">Flyout Menu</a>
      <button class="shrink-0 p-1">
          <span class="sr-only">Show submenu for "Flyout Menu"</span>
          <svg class="w-3 h-3 fill-slate-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
              <path d="M10 2.586 11.414 4 6 9.414.586 4 2 2.586l4 4z" />
          </svg>
      </button>
      <!-- 2nd level menu -->
      <ul
          class="origin-top-right absolute top-full left-1/2 -translate-x-1/2 min-w-[240px] bg-white border border-slate-200 p-2 rounded-lg shadow-xl  [&[x-cloak]]:hidden"
          x-show="open"
          x-transition:enter="transition ease-out duration-200 transform"
          x-transition:enter-start="opacity-0 -translate-y-2"
          x-transition:enter-end="opacity-100 translate-y-0"
          x-transition:leave="transition ease-out duration-200"
          x-transition:leave-start="opacity-100"
          x-transition:leave-end="opacity-0"
          x-cloak                
      >
          <li>
              <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                  <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                      <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="9" height="12">
                          <path d="M8.724.053A.5.5 0 0 0 8.2.1L4.333 3H1.5A1.5 1.5 0 0 0 0 4.5v3A1.5 1.5 0 0 0 1.5 9h2.833L8.2 11.9a.5.5 0 0 0 .8-.4V.5a.5.5 0 0 0-.276-.447Z" />
                      </svg>
                  </div>
                  <span class="whitespace-nowrap">Priority Ratings</span>
              </a>
          </li>
          <li>...</li>
      </ul>
  </li>
Enter fullscreen mode Exit fullscreen mode

In a nutshell, we’ve set up a variable called open, initially set to false. It becomes true when the user hovers over the element with the mouse, and goes back to false on exit. The classes we defined in the x-transition:* attributes will handle the rest.

Also, notice the [[x-cloak]]:hidden on the <ul< element. This little trick keeps the submenu hidden when the page loads, preventing users from catching a glimpse of it before Alpine.js is fully loaded.

Make the fly-out menu fully accessible

Accessibility is a must! To make our menu accessible, we’ll start by adding some ARIA attributes. Specifically, we’re going to use aria-expanded for both our top-level link and the button that toggles the submenu. This attribute can have values true and false. When the menu’s open, it’s true; when it’s closed, it’s false.

  <li
      class="p-4 lg:px-8 relative flex items-center space-x-1"
      x-data="{ open: false }"
      @mouseenter="open = true"
      @mouseleave="open = false"               
  >
      <a
          class="text-slate-800 hover:text-slate-900"
          href="#0"
          :aria-expanded="open"
      >Flyout Menu</a>
      <button
          class="shrink-0 p-1"
          :aria-expanded="open"
      >
          <span class="sr-only">Show submenu for "Flyout Menu"</span>
          <svg class="w-3 h-3 fill-slate-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
              <path d="M10 2.586 11.414 4 6 9.414.586 4 2 2.586l4 4z" />
          </svg>
      </button>
      <!-- 2nd level menu -->
      <ul>...</ul>
  </li>
Enter fullscreen mode Exit fullscreen mode

We added a : before aria-expanded because we’re making it dynamic. This way, the attribute’s value changes based on the open variable we set earlier, which toggles between true and false.

Now, a slightly trickier part: handling keyboard navigation.

First, we want the menu to open when the user hits the Enter or Space key while focusing on the button element. We can make this happen by adding a @click event listener and toggling the open variable – a true toggle!

  <button
      class="shrink-0 p-1"
      :aria-expanded="open"
      @click.prevent="open = !open"
  >
      <span class="sr-only">Show submenu for "Flyout Menu"</span>
      <svg class="w-3 h-3 fill-slate-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
          <path d="M10 2.586 11.414 4 6 9.414.586 4 2 2.586l4 4z" />
      </svg>
  </button>
Enter fullscreen mode Exit fullscreen mode

Now, once our menu’s open, users can smoothly navigate using the Tab key. But there’s a bit of a challenge: the dropdown stays open when you reach the last item in the submenu, and the focus jumps to the next item in the top-level menu.

To solve this, we’ll use Alpine.js’s focus plugin. Make sure you’ve imported it in the <head> section right before importing Alpine.js:

  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

With that done, we’ll have access to the $focus magic. Add a @focusout event listener to the <ul> element that forms the submenu. If there are no focused elements within it, we’ll close the menu by setting the open variable to false:

  <ul
      class="origin-top-right absolute top-full left-1/2 -translate-x-1/2 min-w-[240px] bg-white border border-slate-200 p-2 rounded-lg shadow-xl  [&[x-cloak]]:hidden"
      x-show="open"
      x-transition:enter="transition ease-out duration-200 transform"
      x-transition:enter-start="opacity-0 -translate-y-2"
      x-transition:enter-end="opacity-100 translate-y-0"
      x-transition:leave="transition ease-out duration-200"
      x-transition:leave-start="opacity-100"
      x-transition:leave-end="opacity-0"
      x-cloak
      @focusout="await $nextTick();!$el.contains($focus.focused()) && (open = false)"
  >
      <li>
          <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
              <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                  <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="9" height="12">
                      <path d="M8.724.053A.5.5 0 0 0 8.2.1L4.333 3H1.5A1.5 1.5 0 0 0 0 4.5v3A1.5 1.5 0 0 0 1.5 9h2.833L8.2 11.9a.5.5 0 0 0 .8-.4V.5a.5.5 0 0 0-.276-.447Z" />
                  </svg>
              </div>
              <span class="whitespace-nowrap">Priority Ratings</span>
          </a>
      </li>
      <li>...</li>
  </ul>
Enter fullscreen mode Exit fullscreen mode

By doing this, the menu will automatically close when the focus leaves the <ul> element that makes up the submenu!

Here is our final code:

  <nav class="flex justify-center">

      <ul class="flex flex-wrap items-center font-medium text-sm">
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Prospects</a>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">History</a>
          </li>
          <li
              class="p-4 lg:px-8 relative flex items-center space-x-1"
              x-data="{ open: false }"
              @mouseenter="open = true"
              @mouseleave="open = false"                            
          >
              <a
                  class="text-slate-800 hover:text-slate-900"
                  href="#0"
                  :aria-expanded="open"
              >Flyout Menu</a>
              <button
                  class="shrink-0 p-1"
                  :aria-expanded="open"
                  @click.prevent="open = !open"
              >
                  <span class="sr-only">Show submenu for "Flyout Menu"</span>
                  <svg class="w-3 h-3 fill-slate-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                      <path d="M10 2.586 11.414 4 6 9.414.586 4 2 2.586l4 4z" />
                  </svg>
              </button>
              <!-- 2nd level menu -->
              <ul
                  class="origin-top-right absolute top-full left-1/2 -translate-x-1/2 min-w-[240px] bg-white border border-slate-200 p-2 rounded-lg shadow-xl [&[x-cloak]]:hidden"
                  x-show="open"
                  x-transition:enter="transition ease-out duration-200 transform"
                  x-transition:enter-start="opacity-0 -translate-y-2"
                  x-transition:enter-end="opacity-100 translate-y-0"
                  x-transition:leave="transition ease-out duration-200"
                  x-transition:leave-start="opacity-100"
                  x-transition:leave-end="opacity-0"
                  x-cloak
                  @focusout="await $nextTick();!$el.contains($focus.focused()) && (open = false)"
              >
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="9" height="12">
                                  <path d="M8.724.053A.5.5 0 0 0 8.2.1L4.333 3H1.5A1.5 1.5 0 0 0 0 4.5v3A1.5 1.5 0 0 0 1.5 9h2.833L8.2 11.9a.5.5 0 0 0 .8-.4V.5a.5.5 0 0 0-.276-.447Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Priority Ratings</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                                  <path d="M11.953 4.29a.5.5 0 0 0-.454-.292H6.14L6.984.62A.5.5 0 0 0 6.12.173l-6 7a.5.5 0 0 0 .379.825h5.359l-.844 3.38a.5.5 0 0 0 .864.445l6-7a.5.5 0 0 0 .075-.534Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Insights</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="12" height="12">
                                  <path d="M6 0a6 6 0 1 0 0 12A6 6 0 0 0 6 0ZM2 6a4 4 0 0 1 4-4v8a4 4 0 0 1-4-4Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Item Mirror</span>
                      </a>
                  </li>
                  <li>
                      <a class="text-slate-800 hover:bg-slate-50 flex items-center p-2" href="#">
                          <div class="flex items-center justify-center bg-white border border-slate-200 rounded shadow-sm h-7 w-7 shrink-0 mr-3">
                              <svg class="fill-indigo-500" xmlns="http://www.w3.org/2000/svg" width="11" height="11">
                                  <path d="M10.866.134a.458.458 0 0 0-.481-.106L.302 3.695a.458.458 0 0 0-.014.856l4.4 1.76 1.76 4.4c.07.175.24.29.427.29h.007a.458.458 0 0 0 .424-.302L10.973.615a.458.458 0 0 0-.107-.48Z" />
                              </svg>
                          </div>
                          <span class="whitespace-nowrap">Support Center</span>
                      </a>
                  </li>
              </ul>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Contacts</a>
          </li>
          <li class="p-4 lg:px-8">
              <a class="text-slate-800 hover:text-slate-900" href="#">Numbers</a>
          </li>
      </ul>

  </nav>
Enter fullscreen mode Exit fullscreen mode

Conclusions

It was fun? Wasn’t it? You can create unlimited variations of this menu by following this guide!

If you liked this tutorial, you might want to check out our collection of Tailwind CSS tutorials, or our Tailwind CSS templates if you’re looking for ready-made templates or components.

Top comments (0)