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!
Photo: © Zachary DeBottis / pexels.com
What we want to achieve
Our custom menu should meet the following requirements:
- 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.
- 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).
- The menu panel opens next to the trigger button and stays attached on scrolling.
- 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
andShift + 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
andShift + 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