Let's build an Accessible Menu with Modern Web Features

We're all familiar with menu elements on websites. A menu is a widget that offers a list of choices to the user, such as a set of actions. Such menus behave like native operating system menus and should not be confused with navigation menus that are commonly placed in the page header.

Unfortunately, the existing <menu> element doesn't behave as expected. There are plans to adapt the native element, but in the meantime we have to create our own custom menu. We could use a menu component from a UI library. Which represents another dependency for your project, and possibly breaking changes in the future. I'd rather not.

But there's good news! Thanks to new web features like the Popover API and CSS Anchor Positioning, creating your own custom menu element has become a lot easier. Let's build an accessible menu together!

A skateboarder in mid air jump, like a menu panel popping up. Photo: © Zachary DeBottis / pexels.com

What we want to achieve

Our custom menu should meet the following requirements:

  1. The menu is fully accessible, meaning:
    • It conveys its role and status to assistive technologies like screen readers.
    • It supports keyboard operation and has a visible focus indicator.
    • The text and graphical elements have sufficient color contrast.
  2. Inside the menu panel, the user can activate one of several menu items. For the sake of simplicity, we don't support nested menus (maybe I'll look into this in the future).
  3. The menu panel opens next to the trigger button and stays attached on scrolling.
  4. The opening and closing of the menu panel is smoothly animated.

I'll show you how to achieve this step by step. But first, take a look at the result.

Demo: Accessible Menu

I've created a CodePen demo that includes the same menu element four times, placed roughly at the four corners of the screen. This way, you can easily test how the placement of the menu panel takes into account the available space:

Building the Menu Element step by step

Step 1: The basic HTML structure

We define an icon button with an aria-label to communicate its purpose to screen reader users. Next to it, we place a div container with several buttons inside it, creating the menu panel. The attributes role="menu" on the container and role="menuitem" on each button convey the appropriate roles to assistive technologies:

<button id="menu-btn-1" class="menu-btn" aria-label="More options" type="button"> <span>/* icon */</span> </button> <div role="menu" aria-labelledby="menu-btn-1"> <button role="menuitem" type="button">More information</button> <button role="menuitem" type="button">Share</button> <button role="menuitem" type="button">Download the file</button> </div>

With CSS, we style our menu button to have a good target size and proper color contrast. We turn the menu panel into a flex container that arranges its menu items in a column. Here's an excerpt:

button.menu-btn { color: white; background-color: #00514c; /* other styling */ } div[role="menu"] { display: flex; flex-direction: column; margin: 0.25rem 0; /* other styling */ }

So, what have we got? An icon button with an always visible list of buttons next to it. Now we build upon this foundation to create our accessible menu.

Step 2: Using Popover API for the menu panel

Next, we add the popover attribute to our menu panel. This causes the menu panel to be hidden on page load. When the popover is shown, it is put into the top layer so it will sit on top of all other page content.

Now, we want our menu button to control when the menu panel is shown or hidden. To create this connection, we assign an id to the popover element and set the popovertarget attribute with the ID value on the menu button. Here's the end result:

<button popovertarget="menu-content-1" id="menu-btn-1" class="menu-btn" aria-label="More options" type="button"> <span>/* icon */</span> </button> <div popover id="menu-content-1" role="menu" aria-labelledby="menu-btn-1"> /* menu items */ </div>

These simple steps already provide us with a lot of features: The menu button opens and closes the panel, it communicates its state to assistive technologies (like aria-expanded would), we get light-dismiss and some keyboard operability. Find out more in the MDN article Using the Popover API.

One important piece of advice on styling popover content: If you want to use a flexbox or grid layout, you should only change the value of the popover's display property when the popover is open. Otherwise, you would override its display: none value when it is closed, making it always visible. Here's a code example:

div[role="menu"] { flex-direction: column; margin: 0.25rem 0; /* other styling */ &:popover-open { display: flex; } }

Step 3: Panel placement with CSS Anchor Positioning

To establish a visual link between the menu button and its panel, we need to place them next to each other. And we want to ensure that the panel stays tethered to the menu button, e.g., on scrolling. Historically, this would necessitate the use of a JavaScript overlay library. How annoying!

Lucky for us, the new CSS Anchor Positioning feature does the job for us. It is currently part of Interop 2025 and should be supported in all browsers by the end of the year.

In general, you need to define an anchor element using the anchor-name property. Then you would tether another element to this anchor with the position-anchor property. In case of popover elements, you don't need to do any of this. As the specification states:

Some specifications can define that, in certain circumstances, a particular element is an implicit anchor element for another element. Implicit anchor elements can be referenced with the auto keyword in position-anchor.

This applies to a button that serves as a popover control, like our menu button. Which means: We're good to go! Our menu button is implicitly defined as the anchor element of the menu panel.

So where should we place the panel? How about below and to the right of the button? We could achieve this using the anchor() function for inset properties. But I prefer the more elegant and straight-forward position-area property. Take a look:

div[role="menu"] { position: absolute; position-area: end span-end; }

You should think of the anchor element and the space around it as a 3x3 grid. The first value we define refers to the block axis, the second one to the inline axis. So, position-area: end span-end means, that we want to place our menu panel at the end of the block axis (below the button) and to let it span from the center to the end of the inline axis (center to right).

But what happens when the menu button is placed on the right edge of the screen? Or if the page's scroll position places the button at the lower edge of the screen? In both cases, there's not enough space to display the menu panel with all its options below and to the right of the menu button.

We can easily solve this issue with the help of the position-try-fallbacks property. It allows us to specify a list of one or more alternative positions. When the element would otherwise overflow its containing block, the browser will try placing the positioned element in these different fallback positions.

position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline;

We basically tell the browser: If there's not enough space below the menu button, then move the menu panel above the button. Not enough space to the right? Ok, then flip the inline axis and let the menu panel span the space from the left to the center. And if neither of these work alone, then do both (flip-block flip-inline).

Step 4: Keyboard Interaction with JavaScript

Our custom menu should be fully operable for keyboard users. The ARIA APG Menu Pattern defines the following requirements:

  • When a menu opens, keyboard focus is placed on the first item.
  • Tab and Shift + Tab do not move focus among the items in the menu.
  • When focus is in a menu, Down Arrow moves focus to the next item, optionally wrapping from the last to the first.
  • When focus is in a menu, Up Arrow moves focus to the previous item, optionally wrapping from the first to the last.
  • When focus is on a menuitem in a menu, then Tab and Shift + Tab move focus out of the menu, and close all menus and submenus.
  • The Enter key activates the item and closes the menu.
  • Escape closes the menu that contains focus and returns focus to the menu button.

The last requirement is already taken care of thanks to the Popover API. For the rest, we need to implement some JavaScript code and adjust our HTML structure. First, we set tabindex="-1" on all menu items, to take them out of the focus order:

<div popover id="menu-content-1" role="menu" aria-labelledby="menu-btn-1"> <button role="menuitem" tabindex="-1" type="button">More information</button> <button role="menuitem" tabindex="-1" type="button">Share</button> <button role="menuitem" tabindex="-1" type="button">Download the file</button> </div>

I've written a JavaScript class named MenuNavigationHandler that encapsulates the required interactivity of the custom menu element. You simply create a new instance of the class, passing a specific menu element as the parameter. Here's an excerpt from the code:

class MenuNavigationHandler { constructor(menuEl) { this.menuEl = menuEl; this.menuBtn = document.getElementById(this.menuEl.getAttribute("aria-labelledby") ); this.menuItems = Array.from(menuEl.children); this.selectedItem = null; this.selectedItemIndex = 0; // Handle interaction with menu this.menuEl.addEventListener("toggle", (event) => this.onMenuOpen(event)); this.menuEl.addEventListener("keydown", (event) => this.onMenuKeydown(event)); this.menuEl.addEventListener("click", (event) => this.onMenuClick(event)); } // Private methods } const menus = document.querySelectorAll('div[role="menu"]'); menus.forEach(item => new MenuNavigationHandler(item));

The constructor stores references to the menu button and menu panel. It adds event listeners for the popover toggle event as well as keydown and click events on the menu panel. You can dig into all the code in my CodePen demo.

Step 5: Adding Smooth Animations

Last but not least, we want the custom menu to look and feel good. We use CSS transitions to animate the opening and closing of the menu panel.

The panel should fade in and grow in size when it is opened. As the transition applies to a popover that changes from display: none to display: flex, we also need to transition the overlay and display properties. The basic setup looks like this:

div[role="menu"] { transition: opacity, transform, overlay, display; transition-behavior: allow-discrete; }

We want the fade-in to last 120 milliseconds and use a smooth cubic-bezier timing function. At the end of the transition, the popover is fully visible and scaled to 100 percent:

div[role="menu"]:popover-open { /* End of fade-in and start of fade-out */ transition-duration: 120ms; transition-timing-function: cubic-bezier(0, 0, 0.2, 1); opacity: 1; transform: scale(1); }

As the menu panel switches from a hidden to a visible state, there are no computed values that the transition can use as a starting point. Therefore, we use @starting-style to set the starting opacity to 0 and the initial scale to 80 percent:

@starting-style { /* Start of fade-in */ div[role="menu"]:popover-open { opacity: 0; transform: scale(0.8); } }

Now, to define the end values for the fade-out state, we simply leave out the :popover-open pseudo-class and define the styles for the menu element in general. We want a snappier, linear animation that only lasts 100 milliseconds:

div[role="menu"] { /* End of fade-out */ transition-duration: 100ms; transition-timing-function: linear; opacity: 0; transform: scale(1); }

Alright, the animation looks pretty cool already. We could leave it at that. But I want to achieve one more thing: The menu panel should appear to be growing out of the menu button. As we're using the transform property for the scaling effect, we simply need to define its origin like this:

transform-origin: top left;

But there's a problem! The origin should adapt to the placement of the menu panel next to the menu button. For example, when the panel opens to the top and left of the button, then we would need to define bottom right as the origin.

Unfortunately, the position-try-fallbacks we defined don't affect the transform-origin property. I also tried to define custom position options with the @position-try rule. But then I realized that the specification (and browser implementation) only allows a very limited set of properties. It just didn't work.

There's a note in the specification that gives me hope for the future:

It is expected that a future extension to container queries will allow querying an element based on the position fallback it's using, enabling the sort of conditional styling not allowed by this restricted list.

In the meantime, we must resort to good old JavaScript. When the menu panel is opened, we call the following method of the MenuNavigationHandler class to set the appropriate transform-origin:

setTransformOriginOnMenuPanel() { const menuPanelPos = this.menuEl.getBoundingClientRect(); const menuTriggerBtnPos = this.menuBtn.getBoundingClientRect(); let originY = menuTriggerBtnPos.y > menuPanelPos.y ? "bottom" : "top"; let originX = menuTriggerBtnPos.x > menuPanelPos.x ? "right" : "left"; this.menuEl.setAttribute("style", `transform-origin: ${originY} ${originX};`); }

I know it's a bit dirty. But it works! At least in Chrome and Edge. I'll have to thoroughly test the whole custom menu in Firefox and Safari when they finally support CSS anchor positioning.

Bonus: Custom Menu in Angular

If you don't know or care about the JavaScript framework Angular, please skip this section.

You're still here? Alright, then check out my Accessible Popover Menu project. It includes the custom directives CustomMenuTrigger, CustomMenu and CustomMenuItem. These directives encapsulate all the functionality I've described in this article so far. You can use them to efficiently create accessible menu components in your Angular project.

Conclusions

Using Popover API and CSS Anchor Positioning is a blast! They make it so much easier to create complex widgets like a custom menu, as I've shown you. I'll do a thorough test run of the functionality in Firefox and Safari as soon as they also support anchor positioning. But I'm very optimistic! 🤩

I hope this article inspires you to experiment with these new web features yourself. I'm curious to see what kind of widgets and components you build with them!

Posted on