Creating an accessible language picker

In this article, we'll go through the steps of creating a custom language picker and keeping it accessible.

We recently published the Language Picker component. It's a common widget to find on a website but you need to keep a few things in mind during development if you want it to be accessible.

Let's do this! #

The component we build in this tutorial is based on the CodyHouse framework.

👋 First time you hear about the CodyHouse Framework?

Let's go through the steps of creating our custom language picker.

1. Initial HTML structure #

Our starting point will be a <select> (with the list of all possible languages) and its <label> element:

<div class="language-picker js-language-picker">
  <form action="" class="language-picker__form">
    <label for="language-picker-select">Select your language</label>

    <select name="language-picker-select" id="language-picker-select">
      <option lang="de" value="deutsch">Deutsch</option>
      <option lang="en" value="english" selected>English</option>
      <option lang="fr" value="francais">Français</option>
      <option lang="it" value="italiano">Italiano</option>
      <!-- other language options -->

This is what users with JavaScript disabled will see; the form submission can be used to handle the language selection.

Note that each <option> element has a lang attribute (that specifies the language of the text) and that we used the labels in their original language (for example, I'm Italian so I'm expecting to find 'Italiano' as an option, rather than 'Italian').

2. Custom Structure #

Using JavaScript, we can replace the default <select> with a new structure. We'll need:

  • a <button> which will be used as a trigger to open the language list;
  • a dropdown element with the list of all available languages.

First, lets' start by defining the HTML structure that we want to use for these elements.

For the <button> element, we'll have:

<button class="language-picker__button" aria-label="English, Select your language" aria-expanded="false" aria-contols="language-picker-dropdown">
  <span aria-hidden="true" class="language-picker__flag language-picker__flag--english"></span>

Let's analyze the aria attributes we have added to this element:

  • aria-label: this is a string that labels our button and it's announced when the element is selected. It is composed of the selected language (e.g., 'English') and the text of the <label> element (e.g., 'Select your language'). You need this attribute as the <button> may not have a visible text (for example, you just want to show a flag icon inside it).
  • aria-expanded: by default, it is set to false (it tells you the language list is not expanded), but it will be changed to true when the language dropdown is visible.
  • aria-controls: this attribute links the <button> to the element it controls (language list). It is set equal to the id of the dropdown.

The <span> element inside the button is used to create the flag icon and has an aria-hidden="true" (it does not provide any additional info so we don't want it to be announced by Screen Readers).

Let's now take a look at the language list final HTML:

<div class="language-picker__dropdown" aria-describedby="language-picker-description" id="language-picker-dropdown">
  <p class="sr-only" id="language-picker-description">Select your language</p>

  <ul class="language-picker__list" role="listbox">
      <a lang="de" hreflang="de" href="#" role="option" data-value="deutsch" class="language-picker__item language-picker__flag language-picker__flag--deutsch">Deutsch</a>

      <a lang="en" hreflang="en" href="#" aria-selected="true" role="option" data-value="english" class="language-picker__item language-picker__flag language-picker__flag--english">English</a>

    <!-- other language items -->

We have a .language-picker__dropdown element with an id equal to the aria-controls value of our <button>.

We have added an aria-describedby equal to the id of the <p> element inside it. This will provide a description for the dropdown that will be announced by SR when the element is selected.

The <p> element inside the .language-picker__dropdown has a class of sr-only: this class (defined inside the Codyhouse Framework) can be used to visually hide an element, leaving it accessible to SR. You can read more about that on the accessibility global documentation page.

Inside the .language-picker__dropdown, we have an unordered list of languages with a role of listbox (this is a list of options which users can choose).

Each list item contains a link element to the website in the selected language, with a role of option (for the same reason the <ul> has a role of listbox).

One important thing to add here is the lang attribute (defined for each <option> element in the original HTML structure); this way SR will know how to pronounce the language label.

Finally, we have added an aria-selected="true" to the selected language link.

Now that we have the final HTML structure, we can implement the JS code that will handle its creation.

First, we can define a LanguagePicker object:

var LanguagePicker = function(element) {
  this.element = element; = this.element.getElementsByTagName('select')[0];
  this.options ='option');
  this.pickerId ='id');
  // ..

//initialize the LanguagePicker objects
var languagePicker = document.getElementsByClassName('js-language-picker');
if( languagePicker.length > 0 ) {
  for( var i = 0; i < languagePicker.length; i++) {
      new LanguagePicker(languagePicker[i]);

The initLanguagePicker function can take care of creating the custom structure:

function initLanguagePicker(picker) {
  // create the HTML for the custom dropdown elementand and insert it in the DOM
  picker.element.insertAdjacentHTML('beforeend', initButtonPicker(picker) + initListPicker(picker));

function initButtonPicker(picker) { // create the button element -> language picker trigger
  var button = '<button class="language-picker__button" aria-label="'' '+picker.element.getElementsByTagName('label')[0].textContent+'" aria-expanded="false" aria-contols="'+picker.pickerId+'-dropdown">';
  button = button + '<span aria-hidden="true" class="language-picker__flag language-picker__flag--''"></span>';
  return button+'</button>';

function initListPicker(picker) { // create language picker dropdown
  var list = '<div class="language-picker__dropdown" aria-describedby="'+picker.pickerId+'-description" id="'+picker.pickerId+'-dropdown">';
  list = list + '<p class="sr-only" id="'+picker.pickerId+'-description">'+picker.element.getElementsByTagName('label')[0].textContent+'</p>';
  list = list + '<ul class="language-picker__list" role="listbox">';
  for(var i = 0; i < picker.options.length; i++) {
    var selected = picker.options[i].hasAttribute('selected') ? ' aria-selected="true"' : '',
      language = picker.options[i].getAttribute('lang');
    list = list + '<li><a lang="'+language+'" hreflang="'+language+'" href="'+getLanguageUrl(picker.options[i])+'"'+selected+' role="option" data-value="'+picker.options[i].value+'" class="language-picker__item language-picker__flag language-picker__flag--'+picker.options[i].value+'"><span>'+picker.options[i].text+'</span></a></li>';
  return list;

Now that the HTML structure is in place, we can style it:

.js .language-picker__form { 
  // if JavaScript is enabled, hide the default form element
  display: none;

.language-picker__dropdown {
  position: absolute;
  left: 0;
  top: 100%;
  width: 200px;
  background-color: var(--color-bg);
  box-shadow: var(--shadow-sm);
  padding: var(--space-xxs) 0;
  border-radius: 0.25em;
  z-index: var(--zindex-popover);
  // hide the language list by default
  visibility: hidden;
  opacity: 0;
  transition: .2s ease-out;

.language-picker__button[aria-expanded="true"] + .language-picker__dropdown { 
  // show the language list when the aria-expanded attribute of the button element is true
  visibility: visible;
  opacity: 1;
  transform: translateY(4px);

3. Handling Events #

We still need to handle the click on the <button> element that will toggle the dropdown visibility.

// click events
picker.trigger.addEventListener('click', function(){

function toggleLanguagePicker(picker, bool) {
  var ariaExpanded;
  if(bool) {
     ariaExpanded = bool;
  } else {
     ariaExpanded = picker.trigger.getAttribute('aria-expanded') == 'true' ? 'false' : 'true';

  picker.trigger.setAttribute('aria-expanded', ariaExpanded);
  if(ariaExpanded == 'true') {
    picker.dropdown.addEventListener('transitionend', function cb(){
      // once the dropdown is visible -> move focus from trigger to the first language in the list
      picker.dropdown.removeEventListener('transitionend', cb);

When the button is clicked, we change the aria-expanded attribute of the <button> (from false to true and vice-versa); this will update the dropdown visibility (check the CSS code at the end of step 2 for more info about the style).

When the dropdown is open (aria-expanded == true), we also move the focus from the <button> to the first language in the list.

That's pretty much all we had to do in JavaScript!

One last improvement (for keyboard navigation) would be to close the language list when pressing 'Esc':

// listen for key events
window.addEventListener('keyup', function(event){
  if( event.keyCode && event.keyCode == 27 || event.key && event.key.toLowerCase() == 'escape' ) {
    // close language picker on 'Esc'
      moveFocusToTrigger(element); // if focus is still within the dropdown, move it to dropdown trigger
      toggleLanguagePicker(element, 'false'); // close dropdown

Before using the toggleLanguagePicker function (that closes the dropdown), we use the moveFocusToTrigger function; this function checks if the focus is still within the dropdown element and, if it is, it moves it back to the button trigger:

function moveFocusToTrigger(picker) {
  if(picker.trigger.getAttribute('aria-expanded') == 'false') return;
  if(document.activeElement.closest('.language-picker__dropdown') == picker.dropdown) picker.trigger.focus();

That's it! You can find a preview (and the full code) on the Language Picker component demo page.

Feedbacks/suggestions? Get in touch on Twitter.