Why Custom Dropdown Menus Need ARIA Labels
Native HTML <select> elements come with built-in accessibility. Screen readers know how to announce them, keyboards can navigate them, and assistive technologies understand their purpose without any extra work from the developer.
But the moment you build a custom dropdown menu using <div>, <ul>, or <span> elements, all of that built-in accessibility disappears. To a screen reader, your beautifully styled dropdown is just a meaningless block of text.
That is where ARIA (Accessible Rich Internet Applications) labels, roles, and properties come in. They bridge the gap between your custom UI and assistive technologies by telling screen readers what each element is, what state it is in, and how it relates to other elements on the page.
In this guide, we will walk through the exact process of making custom dropdown menus accessible. You will see before-and-after code comparisons for every step so you can see precisely how each ARIA attribute changes the experience for users who rely on assistive technology.
What You Need to Know Before You Start
Before diving into implementation, let’s clarify a few foundational concepts.
The First Rule of ARIA
The W3C’s own ARIA documentation states: “If you can use a native HTML element with the semantics and behavior you require already built in, do so.” In other words, if a standard <select> element meets your design needs, use it instead of building a custom dropdown.
However, there are many valid reasons to build custom dropdowns: complex styling requirements, multi-select functionality, search filtering, grouped options, or inserting rich content like icons and descriptions inside each option. When you do need a custom solution, ARIA is essential.
Key ARIA Attributes for Dropdowns
Here is a quick reference of the ARIA attributes we will use throughout this guide:
| Attribute | Purpose | Example |
|---|---|---|
role="listbox" |
Identifies the dropdown list container | Applied to the <ul> that holds options |
role="option" |
Identifies each selectable item | Applied to each <li> inside the list |
aria-label |
Provides an accessible name when no visible label exists | aria-label="Select a country" |
aria-labelledby |
Points to a visible label element by its ID | aria-labelledby="country-label" |
aria-expanded |
Indicates whether the dropdown is open or closed | aria-expanded="true" or "false" |
aria-haspopup |
Tells the screen reader that a popup will appear | aria-haspopup="listbox" |
aria-activedescendant |
Points to the currently focused option within the list | aria-activedescendant="option-2" |
aria-selected |
Indicates which option is currently selected | aria-selected="true" |
Step 1: Start With the Inaccessible Dropdown (Before)
Let’s start with a common custom dropdown that has zero accessibility support. This is the kind of code you will find in many UI libraries and tutorials that focus only on visual design:
<!-- BEFORE: Inaccessible custom dropdown -->
<div class="dropdown">
<div class="dropdown-toggle" onclick="toggleDropdown()">
Select a fruit
</div>
<ul class="dropdown-menu">
<li onclick="selectOption('Apple')">Apple</li>
<li onclick="selectOption('Banana')">Banana</li>
<li onclick="selectOption('Cherry')">Cherry</li>
</ul>
</div>
What a screen reader “sees” here: A group of generic text elements. There is no indication that this is a dropdown, no way to know if it is open or closed, no keyboard support, and no labeling. A screen reader user would have no idea this is an interactive control at all.
Step 2: Add Roles to Define the Structure
The first thing we need to do is tell assistive technologies what these elements are. We do this with ARIA roles.
<!-- AFTER: Roles added -->
<div class="dropdown">
<button class="dropdown-toggle" onclick="toggleDropdown()">
Select a fruit
</button>
<ul class="dropdown-menu" role="listbox">
<li role="option" onclick="selectOption('Apple')">Apple</li>
<li role="option" onclick="selectOption('Banana')">Banana</li>
<li role="option" onclick="selectOption('Cherry')">Cherry</li>
</ul>
</div>
What changed:
- The trigger
<div>was replaced with a<button>. This is critical because buttons are natively focusable and activatable with the keyboard. Never use a<div>as a button unless you absolutely must. - The
<ul>receivedrole="listbox", which tells screen readers this is a list of selectable options. - Each
<li>receivedrole="option", identifying them as individual choices.
Screen reader experience now: “Select a fruit, button” followed by “listbox” with “Apple, option” etc. This is a huge improvement, but we are not done yet.
Step 3: Add ARIA Labels for Clear Context
Now we need to provide a clear, accessible name for the dropdown so users know what it is for.
You have two choices here:
Option A: Use aria-label (when there is no visible label)
<button
class="dropdown-toggle"
aria-label="Select a fruit"
aria-haspopup="listbox"
>
Select a fruit
</button>
Option B: Use aria-labelledby (when a visible label exists)
<label id="fruit-label">Choose your favorite fruit</label>
<button
class="dropdown-toggle"
aria-labelledby="fruit-label"
aria-haspopup="listbox"
>
Select a fruit
</button>
Which should you use?
- Use
aria-labelledbywhenever a visible label exists on the page. This keeps the visual label and the accessible name in sync. - Use
aria-labelwhen there is no visible label and you need to provide one purely for screen readers. - Do not use both on the same element.
aria-labelledbytakes precedence andaria-labelwill be ignored.
We also added aria-haspopup="listbox" to the button. This tells the screen reader that activating this button will display a listbox popup.
Step 4: Communicate the Expanded/Collapsed State
Screen reader users need to know whether the dropdown is currently open or closed. The aria-expanded attribute handles this.
<!-- AFTER: State management added -->
<button
class="dropdown-toggle"
aria-label="Select a fruit"
aria-haspopup="listbox"
aria-expanded="false"
>
Select a fruit
</button>
You must update this attribute dynamically with JavaScript whenever the dropdown opens or closes:
function toggleDropdown() {
const button = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-menu');
const isExpanded = button.getAttribute('aria-expanded') === 'true';
button.setAttribute('aria-expanded', String(!isExpanded));
menu.style.display = isExpanded ? 'none' : 'block';
}
Screen reader experience now: “Select a fruit, button, collapsed” or “Select a fruit, button, expanded” depending on the state. The user always knows what is happening.
Step 5: Add aria-selected and aria-activedescendant
Next, we need to communicate which option is currently selected and which one is currently focused (these can be different).
<!-- AFTER: Selection tracking added -->
<button
class="dropdown-toggle"
aria-label="Select a fruit"
aria-haspopup="listbox"
aria-expanded="true"
aria-activedescendant="option-apple"
>
Apple
</button>
<ul class="dropdown-menu" role="listbox" aria-label="Fruit options">
<li role="option" id="option-apple" aria-selected="true">Apple</li>
<li role="option" id="option-banana" aria-selected="false">Banana</li>
<li role="option" id="option-cherry" aria-selected="false">Cherry</li>
</ul>
What changed:
- Each option now has a unique
id. aria-selected="true"marks the currently chosen option.aria-activedescendanton the button points to the option that currently has visual focus, allowing screen readers to announce the focused option without moving DOM focus away from the button.- The listbox itself received
aria-label="Fruit options"for additional context.
Step 6: Implement Keyboard Navigation
ARIA attributes alone are not enough. The WAI-ARIA Authoring Practices Guide specifies the keyboard interactions that users expect from a listbox dropdown. Without these, your dropdown is still inaccessible to keyboard-only users.
Here are the expected keyboard interactions:
| Key | Expected Behavior |
|---|---|
| Enter / Space | Opens the dropdown (if closed) or selects the focused option (if open) |
| Arrow Down | Moves focus to the next option |
| Arrow Up | Moves focus to the previous option |
| Escape | Closes the dropdown and returns focus to the trigger button |
| Home | Moves focus to the first option |
| End | Moves focus to the last option |
Here is a JavaScript implementation that handles all of these interactions:
const button = document.querySelector('.dropdown-toggle');
const menu = document.querySelector('.dropdown-menu');
const options = menu.querySelectorAll('[role="option"]');
let currentIndex = -1;
button.addEventListener('keydown', function(e) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
openDropdown();
moveFocus(0);
break;
case 'ArrowUp':
e.preventDefault();
openDropdown();
moveFocus(options.length - 1);
break;
case 'Escape':
closeDropdown();
break;
}
});
menu.addEventListener('keydown', function(e) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
moveFocus(Math.min(currentIndex + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
moveFocus(Math.max(currentIndex - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
selectCurrentOption();
break;
case 'Escape':
closeDropdown();
button.focus();
break;
case 'Home':
e.preventDefault();
moveFocus(0);
break;
case 'End':
e.preventDefault();
moveFocus(options.length - 1);
break;
}
});
function moveFocus(index) {
currentIndex = index;
options.forEach(opt => opt.classList.remove('focused'));
options[currentIndex].classList.add('focused');
button.setAttribute('aria-activedescendant', options[currentIndex].id);
}
function selectCurrentOption() {
options.forEach(opt => opt.setAttribute('aria-selected', 'false'));
options[currentIndex].setAttribute('aria-selected', 'true');
button.textContent = options[currentIndex].textContent;
closeDropdown();
button.focus();
}
function openDropdown() {
menu.style.display = 'block';
button.setAttribute('aria-expanded', 'true');
}
function closeDropdown() {
menu.style.display = 'none';
button.setAttribute('aria-expanded', 'false');
}
Step 7: The Complete Accessible Dropdown
Here is the full before-and-after comparison so you can see the total transformation:
Before (Inaccessible)
<div class="dropdown">
<div class="dropdown-toggle" onclick="toggleDropdown()">
Select a fruit
</div>
<ul class="dropdown-menu">
<li onclick="selectOption('Apple')">Apple</li>
<li onclick="selectOption('Banana')">Banana</li>
<li onclick="selectOption('Cherry')">Cherry</li>
</ul>
</div>
After (Fully Accessible)
<div class="dropdown">
<label id="fruit-label">Choose a fruit</label>
<button
class="dropdown-toggle"
aria-labelledby="fruit-label"
aria-haspopup="listbox"
aria-expanded="false"
aria-activedescendant=""
>
Select a fruit
</button>
<ul
class="dropdown-menu"
role="listbox"
aria-labelledby="fruit-label"
tabindex="-1"
>
<li role="option" id="option-apple" aria-selected="false">Apple</li>
<li role="option" id="option-banana" aria-selected="false">Banana</li>
<li role="option" id="option-cherry" aria-selected="false">Cherry</li>
</ul>
</div>
Common Mistakes to Avoid
Even experienced developers make these mistakes when adding ARIA to custom dropdowns. Here is what to watch out for:
- Using
role="menu"instead ofrole="listbox". Themenurole is intended for application-style menus (like right-click context menus or menu bars). For a dropdown that lets users select a value,listboxwithoptionis the correct pattern. - Forgetting to update
aria-expandeddynamically. Setting it in the HTML but never changing it with JavaScript means the screen reader will always report the wrong state. - Adding ARIA roles but skipping keyboard navigation. ARIA tells screen readers what something is, but it does not add behavior. You must implement keyboard support separately.
- Using
aria-labelandaria-labelledbyon the same element. When both are present,aria-labelledbywins andaria-labelis ignored. Pick one. - Not giving each option a unique
id. Without unique IDs,aria-activedescendantcannot point to the correct option. - Hiding the dropdown with
display: nonebut forgetting to setaria-expanded="false". Always keep your ARIA state in sync with the visual state.
Testing Your Accessible Dropdown
After implementing all the ARIA attributes and keyboard navigation, you need to test. Here is a practical testing checklist:
- Screen reader testing: Test with at least one screen reader. NVDA (free, Windows), VoiceOver (built into macOS/iOS), and TalkBack (Android) are the most common. Verify that the dropdown label, state, and options are all announced correctly.
- Keyboard-only testing: Unplug your mouse and try to use the dropdown using only the keyboard. Can you open it, navigate options, select one, and close it?
- Automated testing tools: Use browser extensions like axe DevTools, WAVE, or Lighthouse accessibility audits to catch any remaining issues. Keep in mind that automated tools only catch about 30-50% of accessibility problems.
- Check focus management: When the dropdown closes, does focus return to the trigger button? When it opens, is the focus handled correctly?
Framework-Specific Tips
If you are working with a JavaScript framework, the same ARIA principles apply, but the implementation details vary slightly.
React
In React (and JSX in general), ARIA attributes use the same names as in HTML. No camelCase conversion is needed:
<button
aria-label="Select a fruit"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-activedescendant={activeId}
onClick={toggleDropdown}
onKeyDown={handleKeyDown}
>
{selectedFruit || 'Select a fruit'}
</button>
Consider using libraries like React Aria (by Adobe) or Downshift (by Kent C. Dodds) that handle all ARIA patterns for you. These libraries have been extensively tested with real assistive technologies.
Vue
Vue handles ARIA attributes naturally in templates:
<button
:aria-expanded="String(isOpen)"
aria-haspopup="listbox"
:aria-activedescendant="activeOptionId"
@keydown="handleKeyDown"
>
{{ selectedOption || 'Select a fruit' }}
</button>
Angular
In Angular, use property binding for dynamic ARIA values:
<button
[attr.aria-expanded]="isOpen"
[attr.aria-activedescendant]="activeOptionId"
aria-haspopup="listbox"
(keydown)="handleKeyDown($event)"
>
{{ selectedOption || 'Select a fruit' }}
</button>
Quick Reference: ARIA Dropdown Checklist
Use this checklist every time you build a custom dropdown:
| Requirement | Attribute/Approach | Done? |
|---|---|---|
Trigger is a <button> (or has role="button" + tabindex="0") |
Native HTML | ☐ |
| Dropdown trigger has an accessible name | aria-label or aria-labelledby |
☐ |
| Trigger announces popup type | aria-haspopup="listbox" |
☐ |
| Open/closed state is communicated | aria-expanded |
☐ |
| List container has correct role | role="listbox" |
☐ |
| Each item has correct role | role="option" |
☐ |
| Each item has a unique ID | id="option-xxx" |
☐ |
| Selected item is marked | aria-selected="true" |
☐ |
| Focused item is tracked | aria-activedescendant |
☐ |
| Full keyboard navigation works | JavaScript event handlers | ☐ |
| Focus returns to button on close | JavaScript focus management | ☐ |
Frequently Asked Questions
What is the difference between aria-label and aria-labelledby?
aria-label provides an accessible name as a string directly on the element. aria-labelledby references the id of another element on the page that serves as the label. Use aria-labelledby when you have a visible label and aria-label when you do not.
Should I use role=”menu” or role=”listbox” for a custom dropdown?
For a dropdown where users select a value (like a form select), use role="listbox" with role="option" for each item. The role="menu" pattern is intended for application-style action menus (like navigation menus or context menus) where each item triggers an action rather than setting a value.
Can I add ARIA labels using CSS?
No. CSS cannot add or modify ARIA attributes. ARIA attributes must be set in the HTML or manipulated through JavaScript. CSS can style elements based on ARIA attributes (e.g., [aria-expanded="true"] { display: block; }), but it cannot create them.
Do I still need ARIA if I use a native select element?
No. Native <select> elements have full accessibility support built in. You only need ARIA when building custom dropdown components from non-semantic HTML elements like <div> or <ul>.
How do I test if my ARIA labels are working correctly?
The most reliable way is to test with an actual screen reader. On Windows, use NVDA (free) or JAWS. On macOS, use VoiceOver (built in, activated with Cmd+F5). Additionally, use browser dev tools: in Chrome, the Accessibility tab in the Elements panel shows you the computed accessible name and role for any element.
Does adding ARIA slow down page performance?
No. ARIA attributes have zero impact on rendering performance. They are metadata that the browser’s accessibility API reads and passes to assistive technologies. There is no performance reason to skip ARIA.
What WCAG success criteria do accessible dropdowns satisfy?
Properly implemented custom dropdowns with ARIA help you meet several WCAG 2.2 success criteria, including 1.3.1 (Info and Relationships), 2.1.1 (Keyboard), 4.1.2 (Name, Role, Value), and 2.4.7 (Focus Visible).
Wrapping Up
Adding ARIA labels to custom dropdown menus is not just about compliance. It is about making your application usable for everyone. The process follows a clear pattern: define what each element is with roles, name it with labels, communicate its state with properties, and add keyboard behavior with JavaScript.
The key takeaway is that ARIA attributes and keyboard navigation must work together. ARIA tells assistive technologies what your dropdown is and what is happening. Keyboard navigation makes it actually usable. One without the other still leaves your dropdown inaccessible.
Start with the checklist above, test with a real screen reader, and you will have a custom dropdown that works for all of your users.

0 Comments