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
tabindexattribute 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.
Developing with Accessibility in Mind