
Accessible Toolbar and Menu Navigation with Roving Tabindex
Complex web interfaces frequently feature compound interactive controls—such as text editor toolbars, filter ribbons, navigational menubars, and segmented button groups. A common accessibility mistake in these compound widgets is allowing every inner button to remain part of the sequential page tab order. When a rich text toolbar contains twenty formatting actions, a keyboard user is forced to press the Tab key twenty times simply to advance past the toolbar, creating severe cognitive friction and motor fatigue.
The WAI-ARIA Authoring Practices Guide (APG) specifies that composite widgets like toolbars (role="toolbar") should present exactly one Tab stop to the surrounding document. Internal navigation between controls is then managed via arrow keys (ArrowLeft, ArrowRight, Home, and End) using the "Roving Tabindex" pattern. Under this paradigm, only the currently active or focused item holds tabindex="0", while all other sibling items are assigned tabindex="-1". When the user navigates with the arrow keys, JavaScript transfers tabindex="0" and focuses the new item, ensuring seamless keyboard accessibility and standards-compliant assistive technology announcements.
Example
<!-- Single Tab stop composite toolbar with roving tabindex -->
<div role="toolbar" aria-label="Text Formatting" class="toolbar">
<button type="button" tabindex="0" aria-label="Bold"><b>B</b></button>
<button type="button" tabindex="-1" aria-label="Italic"><i>I</i></button>
<button type="button" tabindex="-1" aria-label="Underline"><u>U</u></button>
</div>
Implementation
<div class="editor-container">
<!-- ARIA Toolbar with accessible label -->
<div
role="toolbar"
aria-label="Document Formatting"
class="format-toolbar"
id="editor-toolbar"
>
<button
type="button"
class="toolbar-btn"
tabindex="0"
aria-pressed="false"
aria-label="Toggle Bold"
>
<strong>B</strong>
</button>
<button
type="button"
class="toolbar-btn"
tabindex="-1"
aria-pressed="false"
aria-label="Toggle Italic"
>
<em>I</em>
</button>
<button
type="button"
class="toolbar-btn"
tabindex="-1"
aria-pressed="false"
aria-label="Toggle Underline"
>
<u>U</u>
</button>
<span class="toolbar-separator" role="separator" aria-orientation="vertical"></span>
<button
type="button"
class="toolbar-btn"
tabindex="-1"
aria-pressed="false"
aria-label="Align Left"
>
← Left
</button>
<button
type="button"
class="toolbar-btn"
tabindex="-1"
aria-pressed="false"
aria-label="Align Center"
>
↔ Center
</button>
<button
type="button"
class="toolbar-btn"
tabindex="-1"
aria-pressed="false"
aria-label="Align Right"
>
→ Right
</button>
</div>
</div>
<style>
.format-toolbar {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.5rem;
background: #f8fafc;
border: 1px solid #cbd5e1;
border-radius: 6px;
}
.toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.25rem;
height: 2.25rem;
padding: 0 0.5rem;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: #334155;
font-size: 0.875rem;
cursor: pointer;
user-select: none;
outline-offset: 2px;
}
.toolbar-btn:hover {
background-color: #e2e8f0;
}
.toolbar-btn:focus-visible {
outline: 2px solid #2563eb;
background-color: #ede9fe;
}
.toolbar-btn[aria-pressed="true"] {
background-color: #0f172a;
color: #ffffff;
}
.toolbar-separator {
display: inline-block;
width: 1px;
height: 1.5rem;
background-color: #cbd5e1;
margin: 0 0.25rem;
}
</style>
<script>
/**
* Initializes roving tabindex keyboard navigation on a toolbar element.
* @param {HTMLElement} toolbarEl
*/
function setupRovingToolbar(toolbarEl) {
const items = Array.from(toolbarEl.querySelectorAll('button:not([disabled])'));
if (items.length === 0) return;
let currentIndex = items.findIndex((item) => item.getAttribute('tabindex') === '0');
if (currentIndex === -1) {
currentIndex = 0;
items[0].setAttribute('tabindex', '0');
}
function setFocus(newIndex) {
// Deactivate current item in the tab order
items[currentIndex].setAttribute('tabindex', '-1');
// Activate and focus target item
currentIndex = newIndex;
items[currentIndex].setAttribute('tabindex', '0');
items[currentIndex].focus();
}
toolbarEl.addEventListener('keydown', (event) => {
const activeElement = document.activeElement;
const index = items.indexOf(activeElement);
if (index === -1) return;
let nextIndex = null;
switch (event.key) {
case 'ArrowRight':
case 'ArrowDown':
event.preventDefault();
nextIndex = (index + 1) % items.length;
break;
case 'ArrowLeft':
case 'ArrowUp':
event.preventDefault();
nextIndex = (index - 1 + items.length) % items.length;
break;
case 'Home':
event.preventDefault();
nextIndex = 0;
break;
case 'End':
event.preventDefault();
nextIndex = items.length - 1;
break;
default:
return;
}
if (nextIndex !== null) {
setFocus(nextIndex);
}
});
// Handle toggle state on click
toolbarEl.addEventListener('click', (event) => {
const btn = event.target.closest('button');
if (!btn) return;
const isPressed = btn.getAttribute('aria-pressed') === 'true';
btn.setAttribute('aria-pressed', String(!isPressed));
// Keep roving tabindex synced with clicked item
const clickedIndex = items.indexOf(btn);
if (clickedIndex !== -1 && clickedIndex !== currentIndex) {
items[currentIndex].setAttribute('tabindex', '-1');
currentIndex = clickedIndex;
btn.setAttribute('tabindex', '0');
}
});
}
// Initialize toolbar
const toolbar = document.getElementById('editor-toolbar');
if (toolbar) {
setupRovingToolbar(toolbar);
}
</script>
Main Point: Roving tabindex restricts compound toolbars to a single Tab stop while providing swift and accessible directional arrow key navigation among child actions.
Credits
Photo by Dan Freeman on Unsplash