Building accessible select component in React — cover picture.

Building accessible Select component in React

Kacper Kula
Engineers @ The LEGO Group

--

Select components are everywhere on the internet. The little dropdowns are the core of the web as we know it and have been with us since HTML 2.0 Specification was published in 1995.

As powerful as they are, their functionality is limited. They are unable to show pictures or other secondary data or get the list of its elements customised. In those cases, we might consider creating a custom element.

In this article I will walk you through the following topics:

  • When should you think of implementing custom select and when you’re better off with the native one
  • Why accessibility matters
  • How to build accessible React components using ARIA properties and add a custom hook to handle keyboard navigation
  • How to mimic select functionality
  • How to mimic select keyboard events
  • How to provide the best experience for users who prefer reduced motion

Should I build custom select?

In many cases, you might not need to build custom select. The native element allows for a lot of customisation so you can make it look consistent with your design. Many organisations have chosen to go the native route and opted in for default selects, including GOV.UK, SalesForce, IBM, Shopify. Check out their Design Systems to see how they managed to provide a consistent look while sticking to a native component.

In many cases, native dropdown might be a limiting solution though. You are restricted to the structure the select element provides. You cannot use images as options. You cannot add secondary information like extra tags adding more secondary information about each option.

In the case of our dropdown at the LEGO Group, we wanted to show extra secondary data like the label if the entry is new. Default select does not provide any mechanism to achieve that. We decided to implement select from scratch, keeping accessibility in mind in all the steps of the development journey.

Why accessibility?

There are many reasons why you should consider accessibility at every step of your process.

Disability is all around us

According to the World Report on Disability, 15% of the world's population lives with some sort of disability. Making your website non-accessible means turning 1 in 7 people down. On top of creating virtual barriers, you can lose potential customers too.

We all need some kind of accessibility

When talking about accessibility we might tend to think of people with permanent disabilities. But in reality majority of us already had or will experience some kind of temporary disability in our life. You might break your arm or leg and be incapacitated for a few months. You might get an eye infection and get temporarily blinded. You might stand too close to the audio equipment at the music gig and get short-term hearing problems.

A few years ago I was a victim of an accident that made me unable to use my arm for a month. During this time I resorted to accessibility features to be able to navigate quickly on the websites and continue doing my job. I described my experience in one of my past articles:

Contractual requirements

A more pragmatic reason is that there might be contractual requirements imposed on your organisation to make sure your web application is accessible.

In the US “Businesses that fall under ADA Title 1 or ADA Title 3 must have a website that gives ‘reasonable accessibility to people with disabilities.” [1].

European Union passed European Accessibility Act in 2019.

How to prepare accessible select?

Rules for creating accessible elements:

  • Design and implement elements in a way that it’s clear what its function is — a dropdown should look like a dropdown, an input should look like input, etc.
  • Make sure navigation can be done without using a mouse
  • Make sure all elements are semantically correct
  • Make sure to add proper aria attributes
  • Test, test, test

Building the main scaffold

Before we start styling our components, we should build an accessible scaffold. Without that, adding accessibility later could be tricky.

Below is the basic structure we will be using:

Step 1. Scaffold

Testing for accessibility

We did not add any accessibility features yet. If we try to enable VoiceOver and navigate to the button, it will be announced as a regular button. We need to specify what roles each of the elements has and how they relate to each other. We can do it using ARIA attributes.

ARIA stands for Accessible Rich Internet Applications and is a set of roles and attributes to make web applications more accessible for people with disabilities by adding meaning to the elements which otherwise have no meaning when used with accessible tools.

There are multiple tools to test accessibility. In this article, I will show 2 of them which were the most helpful for me: MacOS VoiceOver and Chrome Developer Tools Accessibility tab.

macOS VoiceOver

To turn on MacOS VoiceOver you can head to System Preferences → Accessibility → Vision → VoiceOver. From this window, you can also turn on Show Accessibility in menu bar for quick access.

macOS Accessibility settings window with VoiceOver option selected.

If you are not working on macOS, there are equivalent tools available for other systems. Most Gnome-based distributions like Ubuntu comes with Orca Screen Reader which can be turned on in the settings. On Windows, you can turn on Narrator using Windows Key + Ctrl + Enter. You can also install free NVDA screen reader for Windows.

Chrome Developer Tools Accessibility Pane

A great tool to debug accessibility is Chrome Developer Tools Accessibility Pane. Once you open it and select an element to inspect, it will summarise all ARIA attributes and mark the ones which are invalid. It will also show you the accessibility tree which is used by screen readers to determine the structure of your website. Similar feature is available in Firefox Developer Tools.

Adding accessibility features

Once the VoiceOver is on, it will describe what’s currently focused on your website. If we focus on our button we will see that it’s identified as a button. In our case we want the button to represent a select. To do that we need to change its role. Before we start adding the roles, let’s learn (or refresh) what ARIA roles are:

“ARIA roles provide semantic meaning to content, allowing screen readers and other tools to present and support interaction with object in a way that is consistent with user expectations of that type of object.”

WAI-ARIA Roles — MDN Web Docs

In our case we need to use composite widget roles: our button should have the role of combobox representing an element that controls another one. It will be controlling our list of options which role can be defined as a listbox: representing lists from which users may select one (or more) items.

Each role determines the ARIA attributes the field requires to work properly. For our combobox, we need to define which element it controls, if the element is currently expanded and which descendant is currently active. To do that we can add the following properties:

Step 2. Adding ARIA attributes to the button

For our list, we need to update its role and remote it from sequential keyboard tab navigation. We also mark it as not multi-selectable in our example. Here’s the documentation for all valid aria attributes for listbox.

Step 2. Adding ARIA attributes to the dropdown

Each of our list options should also get new attributes:

  • role=optionto represent a valid option
  • aria-selected for the currently selected option.
  • id — to be able to identify them in the button’s aria_activedescendant.

You can find the list of valid aria attributes for role=option on MDN Web Docs.

Step 2. Adding ARIA attributes to options.

Finally, we can use our new aria-* values to style our component:

Step 2. Styling based on ARIA attributes.

By tying up aria properties with styles (instead of adding custom classes) we ensure that other developers will most likely keep the aria values up to date. Otherwise, an engineer who is not experienced with accessibility might forget to update those values when refactoring the component.

Below you can see the updated version:

Step 2. Added ARIA attributes

If you run voiceOver and tab to the select element you will see it is now announced differently. Also, when hitting Enter the focus should switch to the currently selected element and this one should get announced too. Great job!

Accessibility differences between browsers

When adding accessibility features, it is vital to test it in as many browsers as possible. Even though there are standards in place, every browser behaves slightly different. Below are some of the observations I’ve made along the way:

  • Chrome seems to be the most forgiving of the browsers, it is great for users, but, as a developer you should not rely the results it gives you to judge if the feature is accessible on other browsers.
  • For accessibility to work on Firefox, the target listbox cannot be hidden from Aria tree at any time — make sure you do not apply aria-hidden or display: none for it, rather hide it visually, for example by making container 0 by 0 px with overflow: hidden.
  • Safari has a bug (in the time of writing, August 2022) which makes listbox not being announced unless you focus it manually. This bug breaks a lot of otherwise accessible select elements showcased online, including official W3C ARIA example. This can be fixed by ensuring proper focus when opening dropdown on Safari:
“Hack” to provide better accessibility on Safari.

This shows how crucial it is to test accessibility of your application in as many browsers as possible.

Adding mouse interactivity

Now we need to add interactivity to our elements. Because we use labels and radio elements all that we need to do is to add an onChange handler to allow users to select it.

We should also add a new state to capture which element the user is hovering on. Thanks to that we can update our aria-selected to update screen readers about the current selection.

Step 3. Adding mouse interactivity

You can check the working demo for this step here:

Step 3. Added mouse interactivity

Adding keyboard interactivity

Next, we can handle keyboard navigation. We should provide exactly the same experience as a user would have had when using a native select element. Most notably:

When select is open you should be able to:

  • Move up and down using ArrowUp and ArrowDown.
  • Select an element by pressing either Space or Enter
  • Collapse dropdown without selecting by pressing Escape
  • Move to the first element by pressing PageUp or Home
  • Move to the last element by pressing PageDown or End

When select is closed you should be able to:

  • Open it by pressing either Enter, Space, ArrowUp or ArrowDown

To achieve that we can create a custom hook that will register proper events. We can also move all our state management code to this hook so it is easily testable.

Step 4. Custom hook to handle keyboard events. The Safari hack not included for clarity.

The most important part in the code above for keyboard handling is done in useEffect. Depending on the dropdown state we either register our handlers for open dropdown or for closed dropdown. The handlers are as follows:

Step 4. Reacting to the keyboard when the dropdown is closed.

When dropdown is closed we open it by using ArrowUp, ArrowDown, Space or Enter.

To deal with an open state we need to add a bit more logic:

Step 4. Reacting to the keyboard when the dropdown is open.

The code above should handle properly all common select use cases.

Below is an updated version:

Step 4. Version with keyboard navigation working.

Styling component

Almost all accessible functionality is done. Now we can style our component to look like an actual dropdown. We can also add some functionality that we wouldn’t be able otherwise: in our case, we will add extra tags providing additional information about the entries:

Screenshot of custom dropdown component. Some elements have custom labels like “Lloyd, labeled Life”, “Kai, labeled Fire”.
Our final designs.

We can also add extra information for people using accessive technology: they might not be able to determine the role of our tags. We can add an extra description for them that will be read out by the VoiceOver so they know those are labels:

Step 5. Adding ability to show tags. Also, added hidden text that will be visible by screen readers to add some extra context.

We can also add animation to make the component feel like the dropdown actually “drops” from the button. To do so we can use scale transformation to achieve a snappy result:

Step 5. Adding short animation for dropdown to “drop” from top.

You can check the final version below:

Step 5. Finished styling.

Extra enhancements

The dropdown is almost done but there are 2 extra enhancements we could make:

  • Closing dropdown when clicking outside it
  • Respecting prefers-reduced-motion setting

Closing dropdown when clicking outside

If you click outside the dropdown it does not collapse. We need to add custom code to allow for that. We cannot use onBlur button callback directly. If you try to click on an option the blur makes the dropdown collapse right away and not select the option. Also for Safari we need to manually change our blur making this not a viable option. To achieve it in a more reliable way we should distinguish if the user clicked within our select element or outside it.

We can add a new handler when the dropdown is open and inspect every click performed on the page. The click event contains a target which determines the element that the user clicked on. If they clicked within our select element, one of its parents will be the root of our dropdown. To do so we can use Event.composedPath() that returns an array of elements the event will travel up the tree through. By marking our root with data-namespace attribute we can detect it and not cancel the event in that case.

Enhancement: Determining if the element clicked has the dropdown root on its path.

Adapting to prefers-reduced-motion

Our dropdown has now an animation that plays when dropdown is opening or closing. Unfortunately, this sort of movement can be problematic for some of our users, for example, those with vestibular motion disorder. Luckily, they can set their systems and browser settings to indicate that they prefer reduced motion. It is still up to developers though to respect this setting and provide reduced animations.

Fortunately detecting prefers-reduced-motion is easy. We can disable transition for users who prefers it:

Enhancement: Disabling transition for people who prefer reduced motion.

It’s that easy!

The additional benefit of providing a reduced motion version is that you can use it in your Cypress tests to reduce flakiness. Animations might make elements appear as visible when they should no longer be. To enable it when running cypress you can add --force-prefers-reduced-motion flag to your browser config.

To test it we can either enable it in our system or emulate it in Chrome by pressing the shortcut combination and searching for “Emulate CSS: prefers-reduced-motion: reduced”.

Screenshot from Chrome Developer Tools. Search results for “prefers reduced” — gives result to emulate “prefers-reduced-motion: reduce”

See the final version below:

Final version with enhancements.

What’s your main takeaway from this tutorial? Will your next Select component be custom or will you resort to native select? Let us know in the comments your thoughts!

--

--