DEV Community

Cover image for Creating A Custom, Accessible Drop Down
Emma Bostian ✨
Emma Bostian ✨

Posted on

Creating A Custom, Accessible Drop Down

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>
Enter fullscreen mode Exit fullscreen mode

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:

What we want vs. what we get

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;
     }
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 the id of the HTML which labels it. I'll give it the id of dropdown-label.
  • The <li> functioning as a select drop down must have a role="button" as well as an aria-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 a tabindex="0" so the user can focus on the element.
  • All of the <li> in the drop down menu also need tabindex="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>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 = [];
Enter fullscreen mode Exit fullscreen mode

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;
      }
    });
  });
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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();
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
lexlohr profile image
Alex Lohr

A small addition: it could also help to select the correct WAI ARIA roles for the elements, i.e.

<ul role="listbox">
  <li role="option">
  ...
</ul>
Enter fullscreen mode Exit fullscreen mode

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.

Collapse
 
isfotis profile image
Fotis Papadogeorgopoulos

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" pattern

For example, it lists that the ul is the one to receive focus, and that the li items are not tabbable per se, but aria-activedescendant on the ul 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)

Collapse
 
lexlohr profile image
Alex Lohr

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.

Collapse
 
lkopacz profile image
Lindsey Kopacz

oh i like the addition of roles here.

Collapse
 
equinusocio profile image
Mattia Astorino • Edited

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.

Collapse
 
emmabostian profile image
Emma Bostian ✨

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!

Collapse
 
equinusocio profile image
Mattia Astorino • Edited

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. :)

Thread Thread
 
emmabostian profile image
Emma Bostian ✨

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.

Thread Thread
 
equinusocio profile image
Mattia Astorino • Edited

There are just a lot of things to consider/recreate:

  • Keyboard navigation (arrows and tabs) and activation (enter)
  • Select status (disabled)
  • Options status (disabled, selected, hidden)
  • User accessibility preferences (high contrast, reduced-transparency and reduced-motion)(Check this demo)
  • Options group element (<optgroup>)
  • Dropdown reposition based on the distance from window edges.
  • Dropdown vertical overflow with a long list
  • Mobile usability (custom select are almost always unusable on smarphones)
  • Fieldset element integration...
  • Multiple selection

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.

Collapse
 
niorad profile image
Antonio Radovcic

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.

Collapse
 
equinusocio profile image
Mattia Astorino • Edited

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/

Collapse
 
gabrielecimato profile image
Gabriele Cimato • Edited

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!

Collapse
 
anduser96 profile image
Andrei Gatej • Edited

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:

const $ = document.querySelector.bind(document);
const $$ = document.querySelectorAll.bind(document);

const list = $(".dropdown__list");
const listContainer = $(".dropdown__list-container");
const dropdownArrow = $(".dropdown__arrow");
const listItems = $$(".dropdown__list-item");

Enter fullscreen mode Exit fullscreen mode
Collapse
 
link2twenty profile image
Andrew Bone • Edited

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 😅

Collapse
 
emmabostian profile image
Emma Bostian ✨

Great job!! :)

Collapse
 
cuginoale profile image
Alessio Carnevale • Edited

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 setting aria-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

Collapse
 
danielleeriksen profile image
Danielle Eriksen

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

Collapse
 
gustvandewal profile image
Gust van de Wal

The Morioh article literally links back to here...

Collapse
 
weo3dev profile image
william

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.

Collapse
 
noctivityinc profile image
noctivityinc

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?

Collapse
 
lkopacz profile image
Lindsey Kopacz

Yay! I love this! I'm going to play around with this as well and I'll let you know what I find!

Collapse
 
emmabostian profile image
Emma Bostian ✨

Yasss thanks!

Collapse
 
richardrazo profile image
Richard (Ricky) Razo • Edited

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 {

 padding-right: 22px;
 /*max-width: 80%;*/ <- remove

}

Collapse
 
locness3 profile image
locness3

You cannot place a div inside of a select.

Well, it seems this works on iOS WebKit.

Collapse
 
wtaylor45 profile image
Will Taylor

Awesome! I'll be honest, when I come up with some solutions like this I forget about a11y. Really really important stuff to consider, and you do it beautifully here.

Collapse
 
surajsharma profile image
Suraj Sharma

hmmm interesting but what if you want it to be searchable?

Collapse
 
emmabostian profile image
Emma Bostian ✨

That would be a combobox, not a select dropdown.

Collapse
 
niorad profile image
Antonio Radovcic • Edited

You can add a search-field as first LI and add some JS to filter out the other LIs by its value.

Collapse
 
skbhardwaj profile image
Shrikrishna BHARDWAJ

Hello Emma,

Great post indeed.
Just wanted to know, how would you close the dropdown if we click outside of the dropdown without selecting any option?

Thanks,

Collapse
 
kevsingh98 profile image
kevsingh98 • Edited
function _clickOutside() {
   document.addEventListener("click", function (event) {
     if (event.target.closest(".dropdown")) { //replace the class name to your needs
     return;
     }
     close(); //just calling a function that removes classList of isOpen
     }, false);
}

Hope this can help :)

Collapse
 
naumankhan profile image
Nauman Ahmed Khan

If we have 2 or more dropdowns on single page its not working