Testing aria-haspopup

aria-haspopup indicates that an element triggers a popup, and identifies its type: menu, listbox, tree, grid, or dialog. true is an alias for menu. false is the default, no popup at all. Examples 8 and 9 cover two common markup errors, an invalid value and an empty value, exactly what the companion bookmarklet is built to catch.

Every example below except the dialog (Example 7) opens on hover, click, and keyboard activation. Hover-to-open is standard, expected behaviour for menu, listbox, tree, and grid, hovering only previews, nothing is committed, and moving the mouse away costs nothing. A modal dialog is a different category of interaction: it makes the rest of the page inert and forcibly relocates focus the instant it opens, consequences disproportionate to a cursor passing near a button with no real intent behind it. Opening a destructive-action confirmation from incidental mouse movement is a genuine UX failure, not just a lesser version of the same trade-off the other four make safely, so it's deliberately excluded here rather than included as a cautioned-about example.

Example 1: aria-haspopup="false"

The default. No popup, included as a baseline for contrast with the rest of this page.

Expected result: no popup indication should be announced, this button should behave exactly like any ordinary button.

HTML markup
<button aria-haspopup="false">Plain button</button>

Example 2: aria-haspopup="true" (alias for menu)

true is treated identically to menu. Real keyboard focus moves into the menu when opened, unlike the virtual-focus pattern used by listbox-based comboboxes elsewhere on this site.

Expected result: hovering, clicking, or pressing Enter/Space/Arrow Down on the button should open the menu. Arrow keys should move focus between items, looping from last to first. Enter or Space activates the focused item and closes the menu. Escape closes and returns focus to the button.

HTML markup
<button aria-haspopup="true" aria-expanded="false" aria-controls="menu1-popup">
  Options
</button>
<ul role="menu" id="menu1-popup" aria-labelledby="menu1-button" hidden>
  <li role="menuitem" tabindex="-1">Save</li>
  <li role="menuitem" tabindex="-1">Save As</li>
  <li role="menuitem" tabindex="-1">Print</li>
</ul>

The same pattern as Example 2, written with the explicit menu value rather than its true alias, with different content to keep the two visually distinct.

Expected result: identical interaction to Example 2.

HTML markup
<button aria-haspopup="menu" aria-expanded="false" aria-controls="menu2-popup">
  Actions
</button>
<ul role="menu" id="menu2-popup" aria-labelledby="menu2-button" hidden>
  <li role="menuitem" tabindex="-1">Edit</li>
  <li role="menuitem" tabindex="-1">Duplicate</li>
  <li role="menuitem" tabindex="-1">Delete</li>
</ul>

Example 4: aria-haspopup="listbox"

A closed-by-default single-select dropdown. The button shows the current selection; opening it reveals a list of options.

Expected result: opens on hover, click, or Enter/Space/Arrow Down. Arrow keys move the selection (select-follows-focus). Enter, Space, or clicking an option confirms the selection, closes the popup, updates the button text, and returns focus to the button. Escape closes without changing the selection.

HTML markup
<button aria-haspopup="listbox" aria-expanded="false" aria-controls="listbox-popup">
  Sort by: Newest
</button>
<ul role="listbox" id="listbox-popup" aria-labelledby="listbox-button" hidden>
  <li role="option" tabindex="-1" aria-selected="true">Newest</li>
  <li role="option" tabindex="-1" aria-selected="false">Oldest</li>
  <li role="option" tabindex="-1" aria-selected="false">Most popular</li>
  <li role="option" tabindex="-1" aria-selected="false">Alphabetical</li>
</ul>

Example 5: aria-haspopup="tree"

A closed-by-default hierarchical browser, categories that expand to reveal sub-categories. Both categories start already expanded, so the hierarchy is visible the moment the popup opens, rather than requiring Arrow Right first just to discover the structure exists. This is a deliberate choice: without it, this example can look almost identical to Example 4's flat listbox until you happen to try expanding something.

Expected result: opens on hover, click, or Enter/Space/Arrow Down, already expanded, both categories and all four sub-items visible immediately. Arrow Down/Up move between visible items. Arrow Right on an expanded category moves into its first child; Arrow Left on a child moves back to its parent, or collapses an expanded category if focus is already there. Enter or Space on a leaf item selects it, closes the popup, updates the button, and returns focus to the button. Escape closes without selecting.

HTML markup
<button aria-haspopup="tree" aria-expanded="false" aria-controls="tree-popup">
  Browse categories
</button>
<ul role="tree" id="tree-popup" aria-labelledby="tree-button" hidden>
  <li role="treeitem" tabindex="-1" aria-expanded="true">
    Electronics
    <ul role="group">
      <li role="treeitem" tabindex="-1">Phones</li>
      <li role="treeitem" tabindex="-1">Laptops</li>
    </ul>
  </li>
  <!-- Clothing follows the same pattern, also starting expanded -->
</ul>

Example 6: aria-haspopup="grid"

A closed-by-default date picker, a genuinely grid-structured popup rather than a linear list.

Expected result: opens on hover, click, or Enter/Space/Arrow Down. Arrow keys move focus in two dimensions, up/down between weeks, left/right between days. Enter or Space selects the focused date, closes the popup, updates the button, and returns focus to the button. Escape closes without selecting.

HTML markup
<button aria-haspopup="grid" aria-expanded="false" aria-controls="grid-popup">
  Choose a date
</button>
<div id="grid-popup" hidden>
  <table role="grid">
    <caption>March 2026</caption>
    <!-- header row, then date cells, each td focusable via tabindex -->
  </table>
</div>

Example 7: aria-haspopup="dialog"

A confirmation dialog. Unlike every other example on this page, this one deliberately does not open on hover. A modal dialog makes the rest of the page inert and forcibly relocates focus, consequences disproportionate to something as low-intent as a cursor passing nearby. This opens on click or keyboard activation only.

Expected result: opens on click or Enter/Space on the button, not on hover. Focus moves to the dialog heading. Tab is trapped within the dialog while open. Escape, clicking the backdrop, or clicking Cancel closes the dialog and returns focus to the trigger button.

HTML markup
<button aria-haspopup="dialog" aria-expanded="false" aria-controls="dialog-popup">
  Delete item
</button>
<div id="dialog-popup" role="dialog" aria-modal="true" aria-labelledby="dialog-title" hidden>
  <h3 id="dialog-title" tabindex="-1">Delete this item?</h3>
  <p>This action cannot be undone.</p>
  <button>Delete</button>
  <button>Cancel</button>
</div>

Example 8: invalid value

A value that isn't one of the seven valid tokens (false, true, menu, listbox, tree, grid, dialog). "tooltip" is a realistic mistake here, tooltips are genuinely a kind of popup-like UI, so it's an understandable guess, it's just not in the spec's actual enumerated list. No working popup is built for this example, the point is the invalid declaration itself, not the interaction.

Expected result: this is exactly the kind of markup error the bookmarklet is built to catch, an invalid, unrecognised token. Worth confirming what AT does with an invalid value too, whether it's silently ignored (treated as if aria-haspopup weren't present at all), or something else, that's a separate, real question from whether the bookmarklet flags it.

HTML markup
<button aria-haspopup="tooltip">Invalid value example</button>

Example 9: empty value

An empty string is also not one of the seven valid tokens. As with Example 8, no working popup is built here, this is purely a markup-validity case.

Expected result: another case the bookmarklet should flag. Worth confirming directly whether AT treats an empty value the same way as an invalid one (Example 8), the same as false (the spec default), or some third behaviour, this isn't something to assume either way.

HTML markup
<button aria-haspopup="">Empty value example</button>

Note for testing: confirm with your standard browser and screen reader combinations that each popup type is announced correctly, not just "has popup" generically, and that the type-specific keyboard patterns above all behave as described.