# Dropdown parent links that open the submenu, not the page

Source: https://easeweb.dev/learn/menu-link-toggle
Topics: Menus and navigation > Menu items with a submenu can still be followed as links (WCAG 2.1.1, 4.1.2)
The fix: Either keep the top-level link and put a separate button beside it that opens the submenu and says whether it is open, or turn the item into that button and move its page into the list as the first link. Either way, no script cancels a link.
Test with: keyboard, screen reader, touch, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation-hybrid/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/examples/disclosure-navigation/ https://www.w3.org/WAI/tutorials/menus/flyout/ https://webaim.org/discussion/mail_message?id=29255

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A furniture shop's header has three items: Home, Products and About. Products is a link to the shop's overview page, and resting the mouse on it drops down a list: Lamps, Chairs and Rugs. A mouse user moves onto the list to pick a category, or clicks Products to see everything. Both work, because the pointer can rest on the link without pressing it.

A keyboard has no resting. Tab to Products and press Enter, and the site has to choose between the list and the page. One common build chooses both, in turn: the first Enter opens the list and keeps you on the page, and the second follows the link. A screen reader says "Products, link" before the first press and after it, so its user hears nothing happen, presses again, and leaves for the Products page without ever meeting Lamps.

Another common build was written to make the menu work by keyboard: Enter now opens and closes the list, and the link says "collapsed" and "expanded". The overview page has gone missing on the way. A mouse click on Products still goes there; no key does. With a screen reader running, it can go the other way: in browse mode the screen reader often clicks the link itself rather than passing Enter to the page, so its user lands on the Products page and never sees the list.

## Why it fails

A link is announced as a link because pressing it goes somewhere. Here one link has two jobs, going to its page and opening its list, and only a pointer can ask for each on its own: resting on the link for the list, a click for the page. The scripts in these builds decide which job a press gets, and the markup says nothing about the decision.

WCAG 2.1.1 Keyboard asks that everything the page offers can be done from a keyboard. The second build fails it: its key handler cancels Enter on Products and opens the list, while a mouse click still follows the link, so the overview page is one click away for a mouse and out of reach for a keyboard. The first build passes 2.1.1, because a second Enter does reach the page.

4.1.2 Name, Role, Value asks that a control's role and state can be read, and that a change to them is reported. In the first build, Products opens a list, so it has a state, open or closed, and nothing exposes it: the screen reader hears "Products, link" whether the list is open or not. In the second, `aria-expanded` exposes the state, but the role is still link, and the link no longer does what a link does on Enter. "Products, link, collapsed" tells its user that something expands, not that Enter will never take them to the page. A screen reader in browse mode usually turns Enter into a click on the link, which runs no key handler, so what happens depends on whether a screen reader is running.

## Who is affected

What a tap does on a phone depends on the browser as much as the script: some show the list on the first tap and follow the link on the second, some follow it at once. So the same header can behave three ways for three visitors, and none of them is told which.

## What automation and AI miss

A scanner reads `<a href="/products">`, a valid link with a name, and `aria-expanded` is allowed on a link, so nothing is flagged. It doesn't press Enter, so it never finds out that the link goes nowhere, or goes somewhere only the second time. Some published advice on making a dropdown keyboard accessible says to let Enter open the submenu without following the link, and an AI coding tool asked the same thing can write exactly the second build: a key handler on the link that cancels Enter and opens the list, with `aria-expanded` or `aria-haspopup` added. It passes the scan, and it moves the failure from the list to the page.

The audit presses Enter on each top-level link that holds a submenu and has no button beside it, with the link's navigation held back so the page stays put. Where Enter opens the list and never follows the link while a mouse click does, it reports that as a finding. Where Enter opens the list the first time and follows the link the second, or no press follows it at all, it sends a review lead, since a person has to judge what the page says and whether the overview page is linked elsewhere. Tabbing through the header with the mouse aside, and again with a screen reader in browse mode, is still how to confirm either.

## Before: the first press opens the list, the second follows the link

```html
<nav aria-label="Main">
  <ul class="menu">
    <li>
      <a href="/">Home</a>
    </li>
    <li class="has-sub">
      <a href="/products">Products</a>
      <ul>
        <li>
          <a href="/products/lamps">Lamps</a>
        </li>
        <li>
          <a href="/products/chairs">Chairs</a>
        </li>
        <li>
          <a href="/products/rugs">Rugs</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="/about">About</a>
    </li>
  </ul>
</nav>
```

```css
.has-sub { position: relative; }
.has-sub > ul { display: none; position: absolute; top: 100%; left: 0; }
.has-sub.open > ul { display: block; }
```

```js
for (const item of document.querySelectorAll('.has-sub')) {
  const link = item.querySelector(':scope > a');
  const setOpen = (open) => item.classList.toggle('open', open);
  item.addEventListener('mouseenter', () => setOpen(true));
  item.addEventListener('mouseleave', () => setOpen(false));
  link.addEventListener('click', (event) => {
    if (item.classList.contains('open')) return;
    event.preventDefault();
    setOpen(true);
  });
}
```

A mouse opens the list on hover, so its click always follows the link. Enter on a link fires the same click, and that is where it goes wrong:

- The first Enter on Products is cancelled, and the list opens in its place.
- Products is announced as "Products, link" before and after, so nothing says the list opened, or that the next Enter will leave the page.

## Before: Enter opens the list, and only a mouse click follows the link

```html
<nav aria-label="Main">
  <ul class="menu">
    <li>
      <a href="/">Home</a>
    </li>
    <li class="has-sub">
      <a href="/products" aria-expanded="false">Products</a>
      <ul>
        <li>
          <a href="/products/lamps">Lamps</a>
        </li>
        <li>
          <a href="/products/chairs">Chairs</a>
        </li>
        <li>
          <a href="/products/rugs">Rugs</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="/about">About</a>
    </li>
  </ul>
</nav>
```

```css
.has-sub { position: relative; }
.has-sub > ul { display: none; position: absolute; top: 100%; left: 0; }
.has-sub.open > ul { display: block; }
```

```js
for (const item of document.querySelectorAll('.has-sub')) {
  const link = item.querySelector(':scope > a');
  const setOpen = (open) => {
    item.classList.toggle('open', open);
    link.setAttribute('aria-expanded', String(open));
  };
  item.addEventListener('mouseenter', () => setOpen(true));
  item.addEventListener('mouseleave', () => setOpen(false));
  link.addEventListener('keydown', (event) => {
    if (event.key !== 'Enter') return;
    event.preventDefault();
    setOpen(!item.classList.contains('open'));
  });
}
```

The click handler is gone, and a key handler opens the list instead, keeping `aria-expanded` in step. Enter now reaches Lamps, Chairs and Rugs, but:

- Enter on Products only opens and closes the list. The Products page, which a mouse click opens, can't be reached with a key.
- Products is still a link, announced as "Products, link, collapsed", so nothing says that Enter won't take you to its page.
- A screen reader in browse mode usually clicks the link rather than pressing Enter, which runs no key handler, so its user goes to the Products page and the list never opens.

## After: the link, and a button beside it

```html
<nav aria-label="Main">
  <ul class="menu">
    <li>
      <a href="/">Home</a>
    </li>
    <li class="has-sub">
      <a href="/products">Products</a>
      <button type="button" aria-expanded="false" aria-controls="sub-products" aria-label="Products pages">▾</button>
      <ul id="sub-products" tabindex="-1" hidden>
        <li>
          <a href="/products/lamps">Lamps</a>
        </li>
        <li>
          <a href="/products/chairs">Chairs</a>
        </li>
        <li>
          <a href="/products/rugs">Rugs</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="/about">About</a>
    </li>
  </ul>
</nav>
```

```css
.has-sub { position: relative; }
.has-sub > ul { position: absolute; top: 100%; left: 0; }
.has-sub > button { min-width: 24px; min-height: 24px; }
```

```js
for (const item of document.querySelectorAll('.has-sub')) {
  const button = item.querySelector(':scope > button');
  const list = document.getElementById(button.getAttribute('aria-controls'));
  const setOpen = (open) => {
    button.setAttribute('aria-expanded', String(open));
    list.hidden = !open;
  };
  button.addEventListener('click', () => setOpen(list.hidden));
  item.addEventListener('keydown', (event) => {
    if (event.key === 'Escape' && !list.hidden) {
      setOpen(false);
      button.focus();
    }
  });
  // Close when focus leaves the item, and leave focus where the user put it.
  item.addEventListener('focusout', (event) => {
    if (!item.contains(event.relatedTarget)) setOpen(false);
  });
}
```

1. Leave Products as a plain link with no handler, so Enter, a click and a tap all go to the Products page, the first time.
2. Put a native button straight after it, drawn as the chevron, which opens and closes the list with Enter, Space or a click.
3. Name the button "Products pages", so a screen reader and voice control tell it apart from the link, and keep it at least 24 by 24 pixels.
4. Give it `aria-expanded`, kept in step with `hidden`, so it is announced as "Products pages, button, collapsed", then "expanded", and tie it to the list with `aria-controls`.
5. Close the list on Escape and put focus back on the button, and close it when focus leaves the item, without moving focus.
6. Put `tabindex="-1"` on the list, so a click inside it keeps focus within the item and doesn't count as leaving.

This fixes both Befores and passes 2.1.1 and 4.1.2: each control does one job and says which. It is the shape of the W3C's disclosure navigation example with top-level links, and of the WordPress Navigation block's submenus when they open on hover with their arrow shown.

## After: a button, with the page moved into the list

```html
<nav aria-label="Main">
  <ul class="menu">
    <li>
      <a href="/">Home</a>
    </li>
    <li class="has-sub">
      <button type="button" aria-expanded="false" aria-controls="sub-products">Products</button>
      <ul id="sub-products" tabindex="-1" hidden>
        <li>
          <a href="/products">All products</a>
        </li>
        <li>
          <a href="/products/lamps">Lamps</a>
        </li>
        <li>
          <a href="/products/chairs">Chairs</a>
        </li>
        <li>
          <a href="/products/rugs">Rugs</a>
        </li>
      </ul>
    </li>
    <li>
      <a href="/about">About</a>
    </li>
  </ul>
</nav>
```

```css
.has-sub { position: relative; }
.has-sub > ul { position: absolute; top: 100%; left: 0; }
```

```js
for (const item of document.querySelectorAll('.has-sub')) {
  const button = item.querySelector(':scope > button');
  const list = document.getElementById(button.getAttribute('aria-controls'));
  const setOpen = (open) => {
    button.setAttribute('aria-expanded', String(open));
    list.hidden = !open;
  };
  button.addEventListener('click', () => setOpen(list.hidden));
  item.addEventListener('keydown', (event) => {
    if (event.key === 'Escape' && !list.hidden) {
      setOpen(false);
      button.focus();
    }
  });
  // Close when focus leaves the item, and leave focus where the user put it.
  item.addEventListener('focusout', (event) => {
    if (!item.contains(event.relatedTarget)) setOpen(false);
  });
}
```

1. Turn Products into a native button. It only opens and closes the list, with Enter, Space, a click or a tap, and never pretends to be a link.
2. Move the overview page into the list as its first link, "All products", so it is still one press away once the list is open.
3. Give the button `aria-expanded`, kept in step with `hidden`, so it is announced as "Products, button, collapsed", then "expanded", and tie it to the list with `aria-controls`.
4. Close the list on Escape and put focus back on Products, and close it when focus leaves the item.
5. Put `tabindex="-1"` on the list, so a click inside it doesn't count as leaving.

This fixes both Befores and passes 2.1.1 and 4.1.2. The cost falls on mouse users, who now need two clicks to reach the overview page instead of one. It is what the WordPress Navigation block renders when its submenus are set to open on click, except that the block drops the parent page rather than moving it into the list, so add that link yourself.

## Which option to use

Both pass. Keep the link and add a button when the section page matters on its own, as a shop's category or a company's About page usually does: it stays one click, tap or Enter away for everyone, and it is how the W3C example and the WordPress Navigation block draw it. It costs a second, smaller target per item, which needs its own name and enough size. Turn the item into a button when its page is only an index of what the list holds: there is one control per item, one Tab stop and one large target, and the overview page sits at the top of the list.

## Implementation decisions

The button needs a name of its own. Don't give it the link's name: "Products, link" followed by "Products, button" sounds like the same thing twice, and a voice control user saying "click Products" gets a choice of two. "Products pages", or "More Products pages" in the form of the W3C example's "More About pages", says what opens. The chevron is drawn in the button, not the link, so it is the target for the list; a chevron drawn inside the link's text invites people to press it and get the page.

Other parts of a real menu each need their own answer:

- Hover: you may keep opening the list on hover for mouse users. Set the state through the same `setOpen`, so `aria-expanded` stays true while the list shows, and meet 1.4.13: the list stays while the pointer moves onto it and closes on Escape, as [tooltips that vanish on approach](/learn/hover-content) explains.
- Key handlers: don't open the list from a `keydown` handler on the link. A screen reader in browse mode often sends a click rather than the key, so the handler runs for some users and not others.
- `aria-haspopup`: leave it off the link and the button. It announces a menu, and this list is a set of links, not a menu.
- Target size: the chevron button is small by design; at under 24 by 24 pixels it needs space around it, as [tap targets too small and too close together](/learn/target-size) explains.
- Mobile: the same link and button work in a narrow-screen menu, where the list stacks and pushes the next item down. Don't switch to a first-tap-opens rule there.

## Verify the fix

1. Put the mouse aside, Tab through the navigation, and press Enter on each top-level link: each goes straight to its page, the first time, and no link opens a list.
2. Tab to each submenu's button, open it with Enter and with Space, and Tab into the list. `aria-expanded` matches the list on screen after every toggle.
3. With a list open, press Escape: it closes, and focus is on its button. Open it again and Tab or Shift + Tab out of the item: it closes and focus stays where you moved it.
4. With a screen reader in browse mode, move through the navigation with the arrow keys and press Enter on each link and each button. Each link goes to its page, and each button is announced as a button with its own name and as expanded or collapsed.
5. On a touch screen, tap each top-level link once and its button once. At 200% zoom, check that both are on screen and each does only its own job.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the shared header or menu component changes.

## Limits

This guide covers a top-level item that is a link and opens its submenu as well. A submenu that opens only on hover, with no script and no button, and a button that never says whether its list is open, are covered in [hover-only dropdowns, or ones that never say they are open](/learn/nav-menus). Application menus of commands follow the ARIA menu pattern instead. The scripts are minimal sketches without animation, hover opening or nested levels. Test the complete journey, including your narrow-screen menu, with your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`aria-expanded`: Says whether a button’s menu or section is open.

| Screen reader | Browser and system | Press it | Tab to it |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
