---
title: "Accessible Navigation with Drupal Core’s Menu System"
url: "/articles/accessible-navigation-drupal-cores-menu-system"
type: article
date: 2020-07-22
updated: 2021-01-12
---

# Accessible Navigation with Drupal Core’s Menu System

#  Accessible Navigation with Drupal Coreâ&#128;&#153;s Menu System 

 New to Drupal 8.9 and 9.0 is the ability to create the HTML button element within a native Drupal menu you can use to toggle secondary menus in a usable and accessible way.

 By

 [  Mike Herchel   ](/about/mike-herchel) 

 July 22, 2020

  

New to Drupal 8.9 and 9.0 is the ability to create the HTML `<button>` element within a native Drupal menu that can be used to toggle secondary menus (such as drop-downs or mega-menus) in a usable and accessible way.

## Common inaccessible menu patterns

It's common to see links (instead of buttons) used to toggle submenus. The result of this pattern is typically inaccessible for keyboard navigation and assistive devices such as screen readers.

```twig
<!-- Inaccessible pattern -->
<ul class="menu">
  <li class="menu__item">
    <a href="#" class="menu__link">Services</a>
    <ul class="menu menu--level-2"><!-- Submenu items --></ul>
  </li>
  <!-- More top level menu items -->
</ul>
```

Hyperlinks (`<a>` tags) should only be used when navigating to a route where the URL will change. If clicking the element shows or hides something or performs some other action, a `<button>` element should be used. For a more nuanced explanation, see Marcy Sutton's article, [Links vs. Buttons in Modern Web Applications](https://marcysutton.com/links-vs-buttons-in-modern-web-applications).

## Creating buttons using the Drupal admin interface

As stated earlier, Drupal 8.9 and later include support for the button element when creating menu items within Drupal's menu interface. To do so, simply enter `route:<button>` into the *link* field when creating the menu entry.

 ![Add menu link form](/sites/default/files/styles/max_900/public/2020-07/add-menu-link.png.webp?itok=OVMoSFsT)

Once entered, the menu item will be output as a `<button>` element instead of an `<a>` tag. However, your work is not yet done. Assuming that you're going to create a submenu, you need to make the menu respond to click and hover events in an accessible manner.

## Attaching appropriate aria labels to the menu items

To indicate the current state of the `<button>` element, you need to attach the `aria-expanded` attribute. In its default closed state, it should be set to `false`, and then toggle to `true` when the submenu is open.

```html
<li class="menu__item">
  <button aria-expanded="false">Services</button>
  <ul class="menu menu--level-2"><!-- secondary menu items --></ul>
</li>
```

In addition to indicating the state, you need to create a relationship between the parent `<button>` element and its child `<ul>` submenu element. To do this, you first need to create a unique `id` attribute on the submenu's `<ul>` element and then create an `aria-controls` attribute on the `<button>` element that corresponds to the submenu's `id` attribute.

```html
<li class="menu__item">
  <button aria-expanded="false" aria-controls="services-submenu">Services</button>
  <ul id="services-submenu" class="menu menu--level-2"><!-- secondary menu items --></ul>
</li>
```

All of this can be done within the menu's Twig template. However, the aria-attributes should not be created by Twig because you can't be sure that JavaScript is enabled, and the functionality will work properly. These attributes will be added later in the JS.

The `menu.html.twig` template below is adapted from the new [Olivero theme](https://www.drupal.org/project/olivero). This template creates these relationships and adds some useful CSS class names for styling the links and buttons.

```twig
{#
/**
 * @file
 * Theme implementation for a menu.
 *
 * Available variables:
 * - menu_name: The machine name of the menu.
 * - items: A nested list of menu items. Each menu item contains:
 *   - attributes: HTML attributes for the menu item.
 *   - below: The menu item child items.
 *   - title: The menu link title.
 *   - url: The menu link url, instance of \Drupal\Core\Url
 *   - localized_options: Menu link localized options.
 *   - is_expanded: TRUE if the link has visible children within the current
 *     menu tree.
 *   - is_collapsed: TRUE if the link has children within the current menu tree
 *     that are not currently visible.
 *   - in_active_trail: TRUE if the link is in the active trail.
 *
 * @ingroup themeable
 */
#}
{% import _self as menus %}
{#
  We call a macro which calls itself to render the full tree.
  @see https://twig.symfony.com/doc/1.x/tags/macro.html
#}
{% set attributes = attributes.addClass('menu') %}
{{ menus.menu_links(items, attributes, 0) }}
{% macro menu_links(items, attributes, menu_level) %}
  {% set primary_nav_level = 'menu--level-' ~ (menu_level + 1) %}
  {% import _self as menus %}
  {% if items %}
    <ul {{ attributes.addClass('menu', primary_nav_level) }}>
      {% set attributes = attributes.removeClass(primary_nav_level) %}
      {% for item in items %}
        {% if item.url.isrouted and item.url.routeName == '<nolink>' %}
          {% set menu_item_type = 'nolink' %}
        {% elseif item.url.isrouted and item.url.routeName == '<button>' %}
          {% set menu_item_type = 'button' %}
        {% else %}
          {% set menu_item_type = 'link' %}
        {% endif %}
        {% set item_classes = [
            'menu__item',
            'menu__item--' ~ menu_item_type,
            'menu__item--level-' ~ (menu_level + 1),
            item.in_active_trail ? 'menu__item--active-trail',
            item.below ? 'menu__item--has-children',
          ]
        %}
        {% set link_classes = [
            'menu__link',
            'menu__link--' ~ menu_item_type,
            'menu__link--level-' ~ (menu_level + 1),
            item.in_active_trail ? 'menu__link--active-trail',
            item.below ? 'menu__link--has-children',
          ]
        %}
        <li{{ item.attributes.addClass(item_classes) }}>
          {#
            A unique HTML ID should be used, but that isn't available through
            Twig yet, so the |clean_id filter is used for now.
            @see https://www.drupal.org/project/drupal/issues/3115445
          #}
          {% set aria_id = (item.title ~ '-submenu-' ~ loop.index )|clean_id %}
          {% if menu_item_type == 'link' or menu_item_type == 'nolink' %}
            {{ link(item.title, item.url, { 'class': link_classes }) }}
            {% if item.below %}
              {{ menus.menu_links(item.below, attributes, menu_level + 1) }}
            {% endif %}
          {% elseif menu_item_type == 'button' %}
            {{ link(link_title, item.url, {
              'class': link_classes,
              'data-ariacontrols': item.below ? aria_id : false,
              })
            }}
            {% set attributes = attributes.setAttribute('id', aria_id) %}
            {{ menus.menu_links(item.below, attributes, menu_level + 1) }}
          {% endif %}
        </li>
      {% endfor %}
    </ul>
  {% endif %}
{% endmacro %}
```

## Show and hide the submenu with CSS

Because the submenu should not be shown until activated, you need to hide it both visually and from the [accessibility tree](https://developer.mozilla.org/en-US/docs/Glossary/AOM) (so assistive devices cannot see it). To do this, you can either use `display: none` or `visibility: hidden` within our CSS.

```css
.menu--level-2 {
  visibility: hidden;
}
```

To show the menu when the `<button>` elementâ&#128;&#153;s `aria-expanded` attribute is toggled to true, use the following CSS selector.

```css
[aria-expanded="true"] + .menu--level-2 {
  visibility: visible;
}
```

## JavaScript to initialize and toggle the submenu

The CSS above will show the submenu when the `<button>`'s `aria-expanded` attribute is `true`. To set this attribute, you need JavaScript.

*Note: The JavaScript examples below use modern syntax and methods that are not compatible with Internet Explorer 11. If you support this browser, make sure that you transpile the JS and include appropriate polyfills.*

### **Create the aria attributes**

Because you don't want to have aria attributes that will indicate non-existent functionality if JS is disabled, wait for JavaScript to add the aria-attributes.

The code snippet below integrates with [Drupal Behaviors](https://www.lullabot.com/articles/understanding-javascript-behaviors-in-drupal) to create the aria attributes for each button.

```js
(Drupal => {
  function initSubmenu(el) {
    el.setAttribute('aria-controls', el.dataset.ariacontrols);
    el.setAttribute('aria-expanded', 'false');
  }

  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu__link--button').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);
```

### **Toggle the submenu on button click**

Below, a `click` event listener is attached to each button, which activates a `toggleSubmenu` function, which flips the `aria-expanded` attribute.

```js
(Drupal => {
  function initSubmenu(el) {
    el.addEventListener('click', toggleSubmenu);
    el.setAttribute('aria-controls', el.dataset.ariacontrols);
    el.setAttribute('aria-expanded', 'false');
  }
  function toggleSubmenu(e) {
    const button = e.currentTarget;
    const currentState = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', !currentState);
  }
  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu__link--button').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);
```

### **Toggling the submenu on hover**

It's a common requirement to show the submenu when hovering over the button with a mouse pointer. To show the menu on hover, toggle the button's `aria-expanded` attribute on `mouseover` and `mouseout` events attached to the button's `<li>` parent. Attaching the events to the parent ensures the menu stays open when the end-user moves the mouse pointer off of the button and onto its submenu.

The refactored JavaScript below expands the `click` event to add `mouseover` and `mouseout` events on the parent `<li>` item.

```js
(Drupal => {
  /**
   * Add necessary event listeners and create aria attributes
   * @param {element} el - List item element that has a submenu.
   */
  function initSubmenu(el) {
    const button = el.querySelector('.menu-link--button');
    button.setAttribute('aria-controls', button.dataset.ariacontrols);
    button.setAttribute('aria-expanded', 'false');
    button.addEventListener('click', e => toggleSubmenu(e.currentTarget, !getButtonState(e.currentTarget)));
    el.addEventListener('mouseover', toggleSubmenu(button, true));
    el.addEventListener('mouseout', toggleSubmenu(button, false));
  }

  /**
   * Toggles the aria-expanded attribute of a given button to a desired state.
   * @param {element} button - Button element that should be toggled.
   * @param {boolean} toState - State indicating the end result toggle operation.
   */
  function toggleSubmenu(button, toState) {
    button.setAttribute('aria-expanded', toState);
  }

  /**
   * Get the current aria-expanded state of a given button.
   * @param {element} button - Button element to return state of.
   */
  function getButtonState(button) {
    return button.getAttribute('aria-expanded') === 'true';
  }

  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu-item--has-children').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);
```

### **Non-JavaScript fallback**

The functionality to show and hide the submenus only works if JavaScript is enabled. Fortunately, you can provide a non-JS alternative with the CSS `:focus-within` and `:hover` pseudo-classes. If any element within the `<li>` receives focus or is hovered over, the submenu will appear.

The selectors below take advantage of the fact that Drupal will attach a `js` CSS class to the HTML element when JavaScript is enabled.

*Note: The `:focus-within`* *pseudo-class is not supported by Internet Explorer 11 or earlier versions of Edge.*

```css
html:not(.js) .menu-item--has-children:focus-within .menu--level-2,
html:not(.js) .menu-item--has-children:hover .menu--level-2,
[aria-expanded="true"] + .menu--level-2 {
  visibility: visible;
}
```

## Conclusion

Drupal front-end developers used to jump through hoops to create `<button>` elements within Drupal's menu structure, but with the advent of this feature, it's easier than ever before. Using patterns similar to those above, you can create super accessible and WCAG compliant menus that are easy for all users to navigate.

*Special thanks to the Drupal core accessibility maintainers for helping me navigate and learn techniques such as these throughout the creation of the core Olivero theme.*

 Published in:

- [  Accessibility   ](/topics/accessibility)
- [  Drupal Development   ](/topics/drupal-development)
- [  Front-end Development   ](/topics/frontend-development)

 

   You must have JavaScript enabled to use this form.

## Get in touch with us

### Tell us about your project. We'd love to hear from you!

 First Name  

 Last Name  

 Your email address  

 Your organization  

 How you heard about us  

 Tell us more 

     Leave this field blank