Note: I'm still learning accessibility, so if you find a flaw in my method, please let me know in the comments below!
Creating custom components is hard. You have to override a lot of default browser styling, and often this can be tedious. And in some instances, it's impossible to style the HTML elements. This is the case with the select drop down.
It's impossible to style the select drop down menu, because we don't have the ability to wrap the set of <option>
elements in a container (which is needed in order to absolutely position the list items against a parent element).
Thus, we must "hack" our way to creating a drop down. Unfortunately, this typically leads to a lack of accessibility.
In this tutorial, we'll learn how to create a custom select drop down, while abiding by the W3C accessibility standards.
Step 1 - HTML
Here is the drop down we're going to be creating:
Traditionally, when creating a select drop down, you would use the following:
<select>
<option value="option-1">Option 1</option>
<option value="option-2">Option 2</option>
<option value="option-3">Option 3</option>
</select>
The issue with using the <select>
element is that you are unable to wrap the child <option>
elements in a container. Why would we need to wrap these elements in a container? In order to position the drop down list underneath the input box.
In our case, we want the list items, <option>
elements, to be positioned underneath the <select>
box. The browser renders the menu items, by default, as an overlay:
To relatively position a child element in relation to a parent element, such is the case with the custom drop down menu, you must set the following CSS properties:
.parent {
position: relative;
}
.child {
position: absolute;
top: 0;
left: 0;
}
You might be wondering: "Can't you re-write the HTML to the following (using the CSS above)?
<select class="parent">
<div class="child">
<option value="option-1">Option 1</option>
<option value="option-2">Option 2</option>
<option value="option-3">Option 3</option>
</div>
</select>
The answer is unfortunately no. You cannot place a <div>
inside of a <select>
.
So we must create an accessible workaround.
Creating A Custom Select
Since we can't use the <select>
element, I'm electing to use a series of <ul>
and <li>
elements.
The structure looks something like this:
<ul class="dropdown">
<li class="dropdown__label">
Label
</li>
<!-- The "select" drop down -->
<li role="button" id="dropdown__selected" tabindex="0">Option 1</li>
<!-- Icon -->
<svg class="dropdown__arrow" width="10" height="5" viewBox="0 0 10 5" fill-rule="evenodd">
<path d="M10 0L5 5 0 0z"></path>
</svg>
<li class="dropdown__list-container">
<ul class="dropdown__list">
<li class="dropdown__list-item" id="option-1">Option 1</li>
<li class="dropdown__list-item" id="option-2">Option 2</li>
</ul>
</li>
</ul>
This is pretty simple.
- We have the entire component wrapped in an unordered list.
- The label is a list item.
- The select is also a list item.
- Next we have the drop down arrow icon. And finally, the list item menu is wrapped in a sub-unordered list.
But... this isn't accessible. If a visually impaired user, with the help of assistive technology, visits this page, they won't have a clue that this is a drop down or how to interact with it. Additionally, it's completely inaccessible by keyboard.
Making The Custom Element Accessible
A custom element must function the same as the semantic elements in regards to keyboard navigation and screen reader accessibility.
Here's what we need in order to make this screen reader accessible:
- The drop down label must have an id. This is because we'll be using
aria-labelledby
on the<li>
which will function as a select drop down, and this attribute accepts theid
of the HTML which labels it. I'll give it the id ofdropdown-label
. - The
<li>
functioning as a select drop down must have arole="button"
as well as anaria-labelledby="dropdown-label"
. - The
<svg>
element needs additional information to describe what it is. Thus, we can add a<title>Open drop down</title>
as the fist child of the SVG. - The drop down list container needs to inform the user whether or not the menu is expanded or not. We can add an
aria-expanded="false"
attribute to communicate this information. This must be updated with JavaScript as the state changes.
Here's what we need in order to make this keyboard accessible:
- The
<li>
which functions as a select drop down needs atabindex="0"
so the user can focus on the element. - All of the
<li>
in the drop down menu also needtabindex="0"
.
Here's the accessible HTML:
<ul class="dropdown">
<li id="dropdown-label" class="dropdown__label">
Label
</li>
<li
role="button"
aria-labelledby="dropdown-label"
id="dropdown__selected"
tabindex="0"
>
Option 1
</li>
<svg
class="dropdown__arrow"
width="10"
height="5"
viewBox="0 0 10 5"
fill-rule="evenodd"
>
<title>Open drop down</title>
<path d="M10 0L5 5 0 0z"></path>
</svg>
<li aria-expanded="false" role="list" class="dropdown__list-container">
<ul class="dropdown__list">
<li class="dropdown__list-item" tabindex="0" id="option-1">
Option 1
</li>
<li class="dropdown__list-item" tabindex="0" id="option-2">
Option 2
</li>
</ul>
</li>
</ul>
We also need to add some JavaScript logic to ensure that the component functions the way a native select drop down would. Here is the expected interaction:
- A user can focus on the element with their keyboard.
- A user can open the select drop down by pressing the Spacebar or Enter keys.
- A user can navigate list item elements with the up and down arrow keys, or the Tab key.
- A user can change the selection by focusing on a list item and pressing Enter.
- A user can dismiss the drop down by pressing Escape.
- Once a user selects a list item, the list should close.
So now let's implement it.
Implementing Keyboard Accessibility With JavaScript
First, we need to grab the keycodes for the Spacebar, Enter key, up and down arrow keys, and the Escape key. (I've seen the Spacebar represented as 0 and 32, so I set it to both to be safe).
const SPACEBAR_KEY_CODE = [0,32];
const ENTER_KEY_CODE = 13;
const DOWN_ARROW_KEY_CODE = 40;
const UP_ARROW_KEY_CODE = 38;
const ESCAPE_KEY_CODE = 27;
Next, there are a few elements we know we'll need. I'll save those to constants. We'll also want to keep track of the list item ids, so I'll declare an empty array which we'll fill up.
const list = document.querySelector(".dropdown__list");
const listContainer = document.querySelector(".dropdown__list-container");
const dropdownArrow = document.querySelector(".dropdown__arrow");
const listItems = document.querySelectorAll(".dropdown__list-item");
const dropdownSelectedNode = document.querySelector("#dropdown__selected");
const listItemIds = [];
Next, we need to add some event listeners to our elements to ensure they will respond to user interaction. Don't worry about the functions declared here, we'll get to them soon.
dropdownSelectedNode.addEventListener("click", e =>
toggleListVisibility(e)
);
dropdownSelectedNode.addEventListener("keydown", e =>
toggleListVisibility(e)
);
// Add each list item's id to the listItems array
listItems.forEach(item => listItemIds.push(item.id));
listItems.forEach(item => {
item.addEventListener("click", e => {
setSelectedListItem(e);
closeList();
});
item.addEventListener("keydown", e => {
switch (e.keyCode) {
case ENTER_KEY_CODE:
setSelectedListItem(e);
closeList();
return;
case DOWN_ARROW_KEY_CODE:
focusNextListItem(DOWN_ARROW_KEY_CODE);
return;
case UP_ARROW_KEY_CODE:
focusNextListItem(UP_ARROW_KEY_CODE);
return;
case ESCAPE_KEY_CODE:
closeList();
return;
default:
return;
}
});
});
Now let's create some of these functions we just called in the event listeners.
setSelectedListItem
takes an event and updates the currently selected item in the "select" box.
function setSelectedListItem(e) {
let selectedTextToAppend = document.createTextNode(e.target.innerText);
dropdownSelectedNode.innerHTML = null;
dropdownSelectedNode.appendChild(selectedTextToAppend);
}
closeList
closes the list and updates the aria-expanded
value.
function closeList() {
list.classList.remove("open");
dropdownArrow.classList.remove("expanded");
listContainer.setAttribute("aria-expanded", false);
}
toggleListVisibility
takes an event. If the Escape key was pressed, close the list. Otherwise, if the user has clicked or if they've pressed the Spacebar or Enter key, toggle the expanded state and update the aria-expanded
value accordingly. Finally, if the down or up arrow keys were pressed, focus the next list item.
function toggleListVisibility(e) {
let openDropDown = SPACEBAR_KEY_CODE.includes(e.keyCode) || e.keyCode === ENTER_KEY_CODE;
if (e.keyCode === ESCAPE_KEY_CODE) {
closeList();
}
if (e.type === "click" || openDropDown) {
list.classList.toggle("open");
dropdownArrow.classList.toggle("expanded");
listContainer.setAttribute(
"aria-expanded",
list.classList.contains("open")
);
}
if (e.keyCode === DOWN_ARROW_KEY_CODE) {
focusNextListItem(DOWN_ARROW_KEY_CODE);
}
if (e.keyCode === UP_ARROW_KEY_CODE) {
focusNextListItem(UP_ARROW_KEY_CODE);
}
}
focusNextListItem
takes a direction which is either the const DOWN_ARROW_KEY_PRESSED
or UP_ARROW_KEY_PRESSED
. If the user is currently focused on the "select", focus on the first list item. Otherwise we need to find the index of the currently focused list item. This is where the listItemsId
array comes in handy. Now that we know where in the list the currently focused item is, we can decide what to do.
If the user pressed the down arrow key, and they're not at the last list item, focus on the next list item. If the user pressed the up arrow key, and they're not at the first list item, focus on the previous list item.
function focusNextListItem(direction) {
const activeElementId = document.activeElement.id;
if (activeElementId === "dropdown__selected") {
document.querySelector(`#${listItemIds[0]}`).focus();
} else {
const currentActiveElementIndex = listItemIds.indexOf(activeElementId);
if (direction === DOWN_ARROW_KEY_CODE) {
const currentActiveElementIsNotLastItem =
currentActiveElementIndex < listItemIds.length - 1;
if (currentActiveElementIsNotLastItem) {
const nextListItemId = listItemIds[currentActiveElementIndex + 1];
document.querySelector(`#${nextListItemId}`).focus();
}
} else if (direction === UP_ARROW_KEY_CODE) {
const currentActiveElementIsNotFirstItem =
currentActiveElementIndex > 0;
if (currentActiveElementIsNotFirstItem) {
const nextListItemId = listItemIds[currentActiveElementIndex - 1];
document.querySelector(`#${nextListItemId}`).focus();
}
}
}
}
And that's it! You now have a fully compliant keyboard-accessible drop down! I won't be covering the Sass/CSS here, but you're welcome to check it out on CodePen.
Top comments (31)
A small addition: it could also help to select the correct WAI ARIA roles for the elements, i.e.
Another minor thing: I prefer to use global event handlers (
document.addEventListener(...)
) and filter the event target after their attributes in the handler if I deal with plain vanilla JS; this way, you can even asynchronously add more of those elements and don't need to add events every time you add a custom select box. Obviously, using a toolkit or framework like React, Angular, Vue or similar, you get the events basically for free.As an addition, when recreating some of the native (or more complex) interactive widgets, I find the WAI-ARIA Authoring Practices a great read.
They offer a list of the expected behaviour (for focus, keyboard etc.), as well as the roles and examples of alternative implementations.
iirc,
<select>
in this case falls under a "Listbox" patternFor example, it lists that the
ul
is the one to receive focus, and that theli
items are not tabbable per se, butaria-activedescendant
on theul
marks the candidate selection. There different ways to do things, of course, which is why I like documents there. The discussion of those patterns on Github has also taught me much about accessibility :)Something else that I am reminded of, Scott Jehl from Filament Group had an article a while back about styling the native
select
element. It has some fun stuff:filamentgroup.com/lab/select-css.html
(There was a comment below that mentioned all this replacement seeming tedious, but I find it fascinating how much gets exposed to assistive tehcnologies out of the box)
One more thing I forgot to mention. You should also ensure that the functionality on different screen sizes works. Imagine a country list with 100+ countries and a web app that cuts of at the bottom of the screen. A maximum height of your options list with overflow: auto; will help, but doesn't solve all edge cases.
oh i like the addition of roles here.
Nice article! I have a question... why do you do this invasive customisation?
Why recreating a custom element that behaves like the native one, inteoducing a lot of code, possible bugs, accessibility issues (keyboard navigation and type filter) that is not integrated with the os (like on mobile)? Does this element deserve all of this time just to have a "custom style"? Is this really an accessibility issue?
With a custom select you will completely lose the native datalist element (which is being imemented by all browsers) and you also have to recreate the optgroup element.
I personally prefer my os integrated dropdown let the platform doing the rest.
Regards.
Hi, so normally we would use the native HTML elements and style them appropriately. And if this were a side project, I'd use the browser styling. But often when building a design system, you use custom elements to convey your design language and branding. Thus, custom-styled elements are necessary. That's why I made this!
I think this is true when you add functionalities to the custom component, but it's not true if you are replicating the exact behaviour by just adding a custom dropdown. This is a sort of "personal taste" that cause a lot of issues to developers and designers. In my opinion such element is a critical one and it should deserve the right considerations. Also i don't think a custom drop down will invalidate a design system effectiveness, this means that you should have and use the native select, and then make a new custom component to that add more funcionalities other than datalist, multiple select and optgroup (that coming with the native select).
BTW that's just my opinion. :)
If you add the proper accessibility considerations, screen reader capability with ARIA and keyboard navigation with tabindex, it's just as accessible as the native element. So there's no accessibility benefit over the native element if they're equal in terms of interaction. If I can make a more visually appealing element that adds value and personality to my app, while ensuring compliance with the W3C standards, I'm going to do it.
There are just a lot of things to consider/recreate:
<optgroup>
)All of these thing must be recreated. A lot of effort and possible breaking bugs just to add a custom experience (not better for all) and a custom style. In design systems these things are considere UX breaking.
BTW, i agree with you that this operation must be done if it provide a real value, but at this point, it's not a custom dropdown, it's just a new component. 👍🏻 Cheers.
As soon as you need integrated search, or icons on the options, the native select won't do. And sometimes you can't talk the client out of it.
Maybe this would work well as Web-Component, also.
Yes this is a classic use case for web components, and if you can't make a web component you should just use both native and custom elements. BTW You can already make a native select with search using the <datalist> element that is being implemented.
jsfiddle.net/equinusocio/yj9fb7Lx/4/
This is impressive work! To be honest it makes me think that building an accessible drop-down shouldn't require all this work. I would hope to just need to add a few things here and there. Nonetheless this is next level! Very well done!
Amazingly, I'd been thinking of a way to do this too. I didn't get too far but had a 'proof of concept' build done.
My version has a long way to go though 😅
Great job!! :)
Hi Emma! Thank you for sharing this, it's very useful!
Here's a quick tip I'd like to share.
Instead of writing
document.querySelector()
/document.querySelectorAll()
multiple times, you could do this:I found this article searching for 'accessible custom dropdowns' and was extremely disappointed to find that it seems to be a blatant copy of this article here, morioh.com/p/0993e06398a1 I know development is all about reuse and sharing solutions, but credit should go to the original author for this content
Fun fact, the article you say that THIS article is a copy of, has a link to CodePen at the bottom that... wait for it... links to Emma's CodePen. So this is the OG, the other is the copy.
The Morioh article literally links back to here...
Thanks for sharing this Emma, a few notes from my side I hope you'll find useful.
Setting max-height and opacity makes the options not visible, but this doesn't mean they are not available to ATs or KB users. Try using the tab key (or a screen-reader) and you will see that those no-longer-visible options can still receive focus and are actually there.
You can fix this using
display: none
or settingaria-hidden: true
.I always test my work using a screen-reader (voiceover) and pretty much all the times I am amazed by the amount of extra work I need to do to make it more user-friendly.
Good to see people taking accessibility seriously!
Keep up with the good work
Yay! I love this! I'm going to play around with this as well and I'll let you know what I find!
Yasss thanks!
This is nice work, except, it doesnt appear to work if the list has an overflow-y: scroll and is longer than the height of the div. Also, it seems to scroll the entire page as well as the list. Anyway to prevent that?
Overall nice work. I like your demo but find it odd that the arrow isn't clickable. I can see this being frustrating to disability users who either use a grid overlay to drill down on an item to be able to click on it, or even those with visual impairments that have spotty dark spots (from diabetes or what not) = so having more of the element clickable would be a plus.
dropdown__selected {
}