Build a Hoverable Dropdown Menu with Tailwind CSS and Alpine.js
Tags: tailwindcss, html, tutorial, webdev
Published: 2023-09-26 11:52:56

####**[Live Demo](https://cruip-tutorials.vercel.app/flyout-menu/)/ [Download](https://github.com/cruip/cruip-tutorials/blob/main/flyout-menu/index.html)**

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](https://cruip.com/simple/?swcfpc=1) or this [SaaS website template](https://cruip.com/open-pro/?swcfpc=1)). 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](https://www.w3.org/WAI/tutorials/menus/flyout/#use-button-as-toggle) 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:

```html
  <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>
```

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:

```html
  <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>
```

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:

```html
  <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>
```

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.

```html
  <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>
```

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!

```html
  <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>
```

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](https://alpinejs.dev/plugins/focus). Make sure you’ve imported it in the `<head>` section right **before** importing Alpine.js:

```html
  <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>
```

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`:

```html
  <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>
```

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:

```html
  <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>
```

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](https://cruip.com/tutorials/), or our [Tailwind CSS templates](https://cruip.com/) if you’re looking for ready-made templates or components.