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
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:
- Define a group of elements in your markup.
- Set
tabindex="0"
to the preselected element andtabindex="-1"
to the rest. - Implement a custom keyboard control that listens to the arrow keys.
- Dynamically re-allocate the
tabindex
attribute depending on the arrow key’s direction and callfocus()
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.
Menus and interactive Lists
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.