tohuwabohu In technology, chaos reigns supreme.

Common Roving Tabindex Use Cases


When I gave you a short technical overview for Web Accessibility, I wanted to cover the basics in order for you to understand why this effort is necessary. For Web Accessibility, developers need to know about certain techniques to make their web content accessible. As a rule of thumb, your website should be navigable by keyboard alone. The roving tabindex is a technique that is crucial to know for some basic use cases because the browser’s default behaviour will be insufficient. Over the next few minutes I will explain this technique using framework-agnostic examples.

Table of Contents

  1. The roving tabindex
  2. Radio Button Groups
  3. Menus and interactive Lists
  4. Tab Panes
  5. Conclusion

The roving tabindex

The tabindex is a global attribute indicating if an element is focusable. Sometimes custom focus handling is necessary. The roving tabindex is a custom implementation where the developer manipulates the tabindex attribute of certain elements to improve user experience for assistive technology. Usually this includes custom keyboard controls because the behavior could not meet the expectations otherwise.

Assuming you had a collection of elements, the roving tabindex technique would make only one of those elements focusable by setting the tabindex to zero, leaving all other elements with tabindex="-1", allowing the user to cycle through the collection with the arrow keys instead of the TAB key.

The general approach looks like this:

  1. Define a group of elements in your markup.
  2. Set tabindex="0" to the preselected element and tabindex="-1" to the rest.
  3. Implement a custom keyboard control that listens to the arrow keys.
  4. Dynamically re-allocate the tabindex attribute depending on the arrow key’s direction and call focus() on the newly chosen element.

Radio Button Groups

Radio button groups are the most basic example when it comes to this. Imagine maintaining a simple form where the user has a set of input controls, one being a radio button group. Let’s look at the following markup.

<form>
    <label for="name">Name:</label>
    <input id="name" type="text">

    <p>Favorite type of food</p>

    <div>
        <label for="chinese">Chinese</label>
        <input type="radio" id="chinese" name="favFood">
        <label for="italian">Italian</label>
        <input type="radio" id="italian" name="favFood">
        <label for="thai">Thai</label>
        <input type="radio" id="thai" name="favFood">
    </div>
    
    <button onclick="submitFoodChoice()">
        Submit
    </button>
</form>

This represents a form where the user can choose between 3 different food options and put his name into. When tabbing through it, the natural focus order presents the name first. In order to reach the <button>, the user would have to cycle through all available radio button options. Focusing a radio button automatically selects it. In this case, the favFood will always be set to thai because the user won’t be able to choose anything else.

Some browsers provide a default implementation for this use case. For others, additional JavaScript code is necessary to achieve the desired behavior. Before we start coding, remove all elements but the one you wish to preselect from the natural focus order and set the proper checked attribute value. I chose italian here.

<form>
    <label for="name">Name:</label>
    <input id="name" type="text">

    <p>Favorite type of food</p>

    <div>
        <label for="chinese">Chinese</label>
        <input type="radio" 
           id="chinese" name="favFood" tabindex="-1">
        <label for="italian">Italian</label>
        <input type="radio" 
           id="italian" name="favFood" tabindex="0" checked="checked">
        <label for="thai">Thai</label>
        <input type="radio"
           id="thai" name="favFood" tabindex="-1">
    </div>

    <button onclick="submitFoodChoice()">
        Submit
    </button>
</form>

This change alone allows the user to jump over the radio button group by pressing the TAB key. With a bit of JavaScript magic, the user can cycle through the whole group with the arrow keys. The radio button group is arranged horizontally, so add a keydown event listener to each radio button that catches the right and left arrow key.

const group = Array.from(
    document.querySelectorAll("input[name='favFood']"));

group.forEach(radio => {
    radio.addEventListener("keydown", (e) => {
        if (e.key !== 'Tab') {
            e.preventDefault();
        }

        switch (e.key) {
            case 'ArrowLeft': {
                // ...
            }
                break;
            case 'ArrowRight': {
                // ...
            }
                break;
            default:
                return;
        }
    });
});

The idea is that an arrow keydown event should jump to the next element in the radio button group depending on the arrow’s direction. Because a radio button group is nothing more than an array or a list of elements, the currently selected index can be incremented or decremented with the array’s bounds in mind.

Define a roveTabindex function just like the one below.

const roveTabindex = (radios, index, inc) => {
    let nextIndex = index + inc;

    if (nextIndex < 0) {
        nextIndex = radios.length - 1;
    } else if (nextIndex >= radios.length) {
        nextIndex = 0;
    }

    console.log(`roving tabindex to index ${nextIndex}`);

    radios[index].setAttribute("tabindex", "-1");
    radios[index].setAttribute("checked", "false");

    radios[nextIndex].setAttribute("tabindex", "0");
    radios[nextIndex].setAttribute("checked", "checked");

    radios[nextIndex].focus();
}

This will transfer the tabindex and checked attributes of the current element to the next one. radios is an array of <input> elements that belong to the favFood group with index being the event target’s index in the radios array. inc is expected to be 1 or -1 depending on the event’s key code.

Put the function calls into the appropriate case arms.

const radios = Array
    .from(document
        .querySelectorAll("input[name='favFood']"));

const thisIndex = radios.indexOf(e.target);

switch (e.key) {
    case 'ArrowLeft': {
        roveTabindex(radios, thisIndex, -1);
    }
        break;
    case 'ArrowRight': {
        roveTabindex(radios, thisIndex, 1);
    }
        break;
    default:
        return;
}

And that’s it. Now you have the roving tabindex custom keyboard controls implemented and taken your first step towards improving accessibility. When navigating with a keyboard only, the previously selected radio button will again be selected automatically, allowing the user to choose other options with the arrow keys.

You can find the working example on my GitHub page and on JsFiddle.

HTML5 introduced the <menu> element that is a <ul> in disguise and was intended for interactive items. Often burger menus for mobile users are built using an unordered list and an own element helps distinguish of what to expect of its items. The matching <menuitem> element has since been deprecated along with some other functionalities of the <menu> element.

When creating an interactive list with <ul> and <li> - or a menu - the appropriate roles should be used. Those are role="menu" for <ul> and role="menuitem" for <li>. Also, the roving tabindex technique helps to improve user experience when navigating with a keyboard only as users really should not have to cycle through all menu items available to get to the next element.

Let’s create a menu with the following markup.

<ul role="menu">
    <li role="menuitem">
        <a href="#">Home</a>
    </li>
    <li role="menuitem">
        <a href="#">Archive</a>
    </li>
    <li role="menuitem">
        <a href="#">Search</a>
    </li>
    <li role="menuitem">
        <a href="#">About</a>
    </li>
    <li role="menuitem">
        <a href="#">Imprint</a>
    </li>
    <li role="menuitem">
        <a href="#">Twitter</a>
    </li>
    <li role="menuitem">
        <a href="#">GitHub</a>
    </li>
</ul>

When entering this menu, the TAB key will automatically jump to the next link. Choose a default element by setting tabindex="0" and tabindex="-1" to anything else. I chose the Home link here.

<ul role="menu">
    <li role="menuitem">
        <a href="#" tabindex="0">
            Home
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            Archive
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            Search
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            About
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            Imprint
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            Twitter
        </a>
    </li>
    <li role="menuitem">
        <a href="#" tabindex="-1">
            GitHub
        </a>
    </li>
</ul>

Again, define a roveTabindex function that sets the tabindex attribute depending on an array index. This time there is no checked attribute that needs to be taken care of.

const roveTabindex = (menu, index, inc) => {
    let nextIndex = index + inc;

    if (nextIndex < 0) {
        nextIndex = menu.length - 1;
    } else if (nextIndex >= menu.length) {
        nextIndex = 0;
    }

    console.log(`roving tabindex to index ${nextIndex}`);

    menu[index].setAttribute("tabindex", "-1");

    menu[nextIndex].setAttribute("tabindex", "0");
    menu[nextIndex].focus();
}

Because the items are arranged vertically, listen for the arrow up and down keys instead of left and right. Also, the ENTER key should not be caught to allow interacting with the underlying link.

const links = Array
    .from(document
        .querySelectorAll("ul[role=menu] > li > a"));

links.forEach(link => {
    link.addEventListener('keydown', (e) => {
        console.log(e.key);

        if (e.key !== 'Tab' 
            && e.key !== 'Enter') {
            e.preventDefault();
        }

        const menu = Array
            .from(document
                .querySelectorAll("ul[role=menu] > li > a"));

        const thisIndex = menu.indexOf(e.target);

        switch (e.key) {
            case 'ArrowUp': {
                roveTabindex(menu, 
                    thisIndex, -1);
            }
                break;
            case 'ArrowDown': {
                roveTabindex(menu, 
                    thisIndex, 1);
            }
                break;
            default:
                return;
        }
    });
});

Now, the menu can be skipped altogether to potentially access content quicker. A working example can be found on my GitHub page and on JsFiddle.

Tab Panes

A tabbed pane can be seen as horizontally aligned menu. Usually, just like accordions, content that belongs to the tab is hidden beforehand or will be dynamically injected into the DOM upon tab selection. For the sake of simplicity I’ll show you an example where only the visibility is toggled. See the markup with 4 tabs and their matching panes below.

<div class="tab-pane">
    <div>
        <ul class="tabbed" role="tablist">
            <li role="tab">
                <button onclick="selectTab(this, 1)" 
                        class="active-tab" 
                        tabindex="0">
                    Tab 1
                </button>
            </li>
            <li role="tab">
                <button onclick="selectTab(this, 2)" 
                        tabindex="-1">
                    Tab 2
                </button>
            </li>
            <li role="tab">
                <button onclick="selectTab(this, 3)" 
                        tabindex="-1">
                    Tab 3
                </button>
            </li>
            <li role="tab">
                <button onclick="selectTab(this, 4)" 
                        tabindex="-1">
                    Tab 4
                </button>
            </li>
        </ul>
    </div>

    <div class="panes">
        <div id="pane1">
            <h2>
                Content 1
            </h2>
            <form>
                <label for="name1">Name 1:</label>
                <input id="name1" type="text">
            </form>
        </div>

        <div id="pane2" style="display: none" aria-hidden="true">
            <h2>
                Content 2
            </h2>
            <form>
                <label for="name2">Name 2:</label>
                <input id="name2" type="text">
            </form>
        </div>

        <div id="pane3" style="display: none" aria-hidden="true">
            <h2>
                Content 3
            </h2>
            <form>
                <label for="name3">Name 3:</label>
                <input id="name3" type="text">
            </form>
        </div>

        <div id="pane4" style="display: none" aria-hidden="true">
            <h2>
                Content 4
            </h2>
            <form>
                <label for="name4">Name 4:</label>
                <input id="name4" type="text">
            </form>
        </div>
    </div>
</div>

Tab 1 is selected by default and the appropriate pane is visible. Any other pane remains hidden for now. Also note the corresponding aria roles tablist and tab for the <ul> and <li> elements.

To toggle visibility of the other panes, a little JavaScript help is necessary. Implement a selectTab function like it’s indicated in the markup.

let selected = 1;

const selectTab = (tab, id) => {
    const pane = document.querySelector(`div[id=pane${selected}]`);
    const next = document.querySelector(`div[id=pane${id}]`);
    const activeTab = document.querySelector('button[class=active-tab]');

    pane.setAttribute("style", "display: none");
    pane.setAttribute("aria-hidden", "true");

    next.setAttribute("style", "display: block");
    next.setAttribute("aria-hidden", "false");

    activeTab.classList.remove('active-tab');
    tab.classList.add('active-tab');

    selected = id;
}

So much for general functionality. By default, pressing TAB will cycle through all tabs first before jumping to the actual content displayed in the matching pane. Implement a roveTabindex function like below.

const roveTabindex = (tabs, index, inc) => {
    let nextIndex = index + inc;
    if (nextIndex < 0) {

        nextIndex = tabs.length - 1;
    } else if (nextIndex >= tabs.length) {
        nextIndex = 0;
    }
    console.log(`roving tabindex to index ${nextIndex}`);

    tabs[index].setAttribute("tabindex", "-1");

    tabs[nextIndex].setAttribute("tabindex", "0");
    tabs[nextIndex].focus();
}

Then, just like for the radio buttons, implement custom keyboard controls that listen for the left and right arrows. The ENTER key allows the user to trigger the button’s onclick function.

const buttons = Array
    .from(document.querySelectorAll("ul[role='tablist'] > li > button"));

buttons.forEach(button => {
    button.addEventListener('keydown', (e) => {
        console.log(e.key);

        if (e.key !== 'Tab' && e.key !== 'Enter') {
            e.preventDefault();
        }

        const tabs = Array
            .from(document
                .querySelectorAll("ul[role='tablist'] > li > button"));

        const thisIndex = tabs.indexOf(e.target);

        switch (e.key) {
            case 'ArrowLeft': {
                roveTabindex(tabs, thisIndex, -1);
            }
                break;
            case 'ArrowRight': {
                roveTabindex(tabs, thisIndex, 1);
            }
                break;
            default:
                return;
        }
    });
});

The working example can also be found on my GitHub page and on JsFiddle.

Conclusion

When making your web content navigable by keyboard alone, techniques like the roving tabindex are essential. As you can see, it’s also not very hard to implement while vastly improving user experience for users that rely on assistive technology. I will show you some more techniques in the following posts, so stay tuned.

Tagged as: accessibility front-end tutorial