roving-tab-index
NPM 1.9.0
Overview
The RovingTabindexController is a tabindex=0 element in the tab order, while making all elements accessible via arrow keys. This enables keyboard users to quickly tab through a page without stopping on every item in a large collection.
Features
- Keyboard navigation: Manages arrow key navigation (Left, Right, Up, Down, Home, End) through collections
- Flexible direction modes: Supports horizontal, vertical, both, and grid navigation patterns
- Focus management: Automatically manages
tabindexattributes on elements - Customizable behavior: Configure which element receives initial focus and how elements respond to keyboard input
- Accessibility compliant: Implements WCAG accessibility patterns for keyboard navigation
Usage
yarn add @spectrum-web-components/reactive-controllers
Import the RovingTabindexController via:
import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js';
Examples
Basic usage
A Container element that manages a collection of <sp-button> elements that are slotted into it from outside might look like the following:
import { html, LitElement } from 'lit'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; class Container extends LitElement { rovingTabindexController = new RovingTabindexController()<Button>(this, { elements: () => [...this.querySelectorAll('sp-button')], }); render() { return html` <slot></slot> `; } }
The above will default to entering the Container element via the first <sp-button> element every time while making all slotted <sp-button> elements accessible via the the arrow key (ArrowLeft, ArrowRight, ArrowUp, and ArrowDown) managed tab order.
Configuration options
A Container can further customize the implementation of the RovingTabindexController with the following options:
directionto customize how and which arrow keys manage what element is to be focused and accepts a either a string ofboth,vertical,horizontal, orgridor a method returning one of those stringselementEnterActionenacts actions other thanfocuson the entered element which accepts a method with a signature of(el: T) => voidelementsprovides the elements that will have theirtabindexmanaged via a method with a signature of() => T[]focusInIndexto control what element will recievetabindex=0while focus is outside of theContainerand accepts a method with a signature of(_elements: T[]) => numberisFocusableElementdescribes the state an element much be in to receivefocusvia a method with a signature of(el: T) => booleanlistenerScopeoutlines which parts on a container's DOM when listening for arrow key presses via an element reference or a method returning an element reference with the signature() => HTMLElement
Horizontal navigation
Restrict navigation to horizontal arrow keys only:
import { html, LitElement } from 'lit'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; import '@spectrum-web-components/button/sp-button.js'; class HorizontalToolbar extends LitElement { rovingTabindexController = new RovingTabindexController<Button>(this, { elements: () => [...this.querySelectorAll('sp-button')], direction: 'horizontal', }); render() { return html` <div role="toolbar" aria-label="Formatting toolbar" aria-orientation="horizontal" > <slot></slot> </div> `; } } customElements.define('horizontal-toolbar', HorizontalToolbar);
Selection with focus
Make the focused element the selected one:
import { html, LitElement, css } from 'lit'; import { property } from 'lit/decorators.js'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; import type { Button } from '@spectrum-web-components/button'; import '@spectrum-web-components/button/sp-button.js'; class SelectableGroup extends LitElement { @property({ attribute: false }) selected?: Button; rovingTabindexController = new RovingTabindexController<Button>(this, { elements: () => [...this.querySelectorAll('sp-button')], direction: 'horizontal', focusInIndex: (buttons) => { return this.selected ? buttons.indexOf(this.selected) : 0; }, elementEnterAction: (button) => { this.selected = button; // Update visual selection this.updateSelection(); }, isFocusableElement: (button) => !button.disabled, }); static styles = css` ::slotted(sp-button[selected]) { background-color: var(--spectrum-global-color-blue-400); } `; updateSelection() { this.querySelectorAll('sp-button').forEach((button) => { button.toggleAttribute('selected', button === this.selected); button.setAttribute( 'aria-selected', String(button === this.selected) ); }); } render() { return html` <div role="radiogroup" aria-label="Options"> <slot></slot> </div> `; } } customElements.define('selectable-group', SelectableGroup);
This usage pattern is similar to what's seen in <sp-radio-group>
Vertical menu navigation
Create a vertical menu with arrow key navigation:
import { html, LitElement, css } from 'lit'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; import '@spectrum-web-components/menu/sp-menu-item.js'; import type { MenuItem } from '@spectrum-web-components/menu'; class VerticalMenu extends LitElement { rovingTabindexController = new RovingTabindexController<MenuItem>(this, { elements: () => [...this.querySelectorAll('sp-menu-item')], direction: 'vertical', isFocusableElement: (item) => !item.disabled, }); static styles = css` :host { display: block; border: 1px solid var(--spectrum-global-color-gray-300); border-radius: 4px; padding: 4px 0; } `; render() { return html` <div role="menu" aria-label="Menu" aria-orientation="vertical"> <slot></slot> </div> `; } } customElements.define('vertical-menu', VerticalMenu);
Grid navigation
Implements a 2D grid navigation:
import { html, LitElement, css } from 'lit'; import { property } from 'lit/decorators.js'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; class GridNavigator extends LitElement { @property({ type: Number }) columns = 3; rovingTabindexController = new RovingTabindexController<HTMLElement>(this, { elements: () => [...this.querySelectorAll('.grid-item')], direction: 'grid', }); static styles = css` .grid { display: grid; grid-template-columns: repeat(var(--columns, 3), 1fr); gap: 8px; } .grid-item { padding: 16px; border: 2px solid var(--spectrum-global-color-gray-300); border-radius: 4px; text-align: center; cursor: pointer; } .grid-item:focus { outline: 2px solid var(--spectrum-global-color-blue-400); outline-offset: 2px; } `; updated() { // Set the grid column count for the controller this.rovingTabindexController.directionLength = this.columns; } render() { return html` <div class="grid" style="--columns: ${this.columns}" role="grid" aria-label="Grid navigator" > <slot></slot> </div> `; } } customElements.define('grid-navigator', GridNavigator);
Tab panel navigation
Implements keyboard navigation for tabs:
import { html, LitElement, css } from 'lit'; import { property } from 'lit/decorators.js'; import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js'; import '@spectrum-web-components/tabs/sp-tab.js'; import type { Tab } from '@spectrum-web-components/tabs'; class TabList extends LitElement { @property({ attribute: false }) selectedTab?: Tab; rovingTabindexController = new RovingTabindexController<Tab>(this, { elements: () => [...this.querySelectorAll('sp-tab')], direction: 'horizontal', focusInIndex: (tabs) => { return this.selectedTab ? tabs.indexOf(this.selectedTab) : 0; }, elementEnterAction: (tab) => { this.selectTab(tab); }, isFocusableElement: (tab) => !tab.disabled, }); selectTab(tab: Tab) { this.selectedTab = tab; // Update ARIA attributes this.querySelectorAll('sp-tab').forEach((t) => { t.setAttribute('aria-selected', String(t === tab)); t.setAttribute('tabindex', t === tab ? '0' : '-1'); }); // Dispatch event this.dispatchEvent( new CustomEvent('tab-select', { detail: { tab }, bubbles: true, composed: true, }) ); } render() { return html` <div role="tablist" aria-label="Content sections"> <slot></slot> </div> `; } } customElements.define('tab-list', TabList);
Accessibility
The RovingTabindexController implements the W3C ARIA Authoring Practices Guide's
Why use roving tabindex?
Without roving tabindex, keyboard users would need to press Tab through every item in a collection (e.g., every button in a toolbar or every option in a listbox). For large collections, this significantly degrades the keyboard navigation experience. Roving tabindex solves this by:
- Including only one element in the tab order (
tabindex="0") - Making all other elements programmatically focusable (
tabindex="-1") - Managing arrow key navigation between elements
- Updating
tabindexvalues as focus moves
ARIA roles and attributes
When using the RovingTabindexController, ensure you apply appropriate ARIA roles and attributes:
<div role="toolbar" aria-label="Formatting tools" aria-orientation="horizontal"> <!-- Managed elements --> </div>
<div role="tablist" aria-label="Content sections"> <button role="tab" aria-selected="true">Tab 1</button> <button role="tab" aria-selected="false">Tab 2</button> </div>
<div role="listbox" aria-label="Options"> <div role="option" aria-selected="false">Option 1</div> <div role="option" aria-selected="false">Option 2</div> </div>
<div role="radiogroup" aria-label="Choices"> <button role="radio" aria-checked="true">Choice 1</button> <button role="radio" aria-checked="false">Choice 2</button> </div>
<div role="menu" aria-label="Actions" aria-orientation="vertical"> <div role="menuitem">New</div> <div role="menuitem">Open</div> </div>
Keyboard support
The RovingTabindexController provides the following keyboard interactions:
Disabled elements
Important: According to
- Options in a Listbox
- Menu items in a Menu or menu bar
- Tab elements in a set of Tabs
- Tree items in a Tree View
For these widgets, use aria-disabled="true" instead of the disabled attribute so items can still receive focus and be read in screen readers' forms/interactive mode:
// For menu items, tabs, listbox options - DO NOT skip disabled items rovingTabindexController = new RovingTabindexController<MenuItem>(this, { elements: () => [...this.querySelectorAll('sp-menu-item')], // Disabled items remain focusable for accessibility isFocusableElement: (item) => true, });
For other controls like buttons or form inputs where disabled items should be skipped:
// For buttons/forms - skip disabled items rovingTabindexController = new RovingTabindexController<Button>(this, { elements: () => [...this.querySelectorAll('sp-button')], isFocusableElement: (button) => !button.disabled, });
<!-- Buttons can use disabled attribute --> <sp-button disabled>Disabled Button</sp-button>
WCAG compliance
The roving tabindex pattern helps meet several WCAG success criteria:
2.1.1 Keyboard (Level A) - All functionality is available from keyboard2.1.3 Keyboard (No Exception) (Level AAA) - No keyboard traps2.4.7 Focus Visible (Level AA) - Focus indicator is visible
References
ARIA Authoring Practices Guide - Roving tabindex ARIA Authoring Practices Guide - Composite Widgets WCAG 2.1 - Keyboard Accessible
Related components
The RovingTabindexController is used by these Spectrum Web Components:
- Tab navigation<sp-tabs> - Radio button groups<sp-radio-group> - Action button groups<sp-action-group> - Menu navigation<sp-menu> - Table keyboard navigation<sp-table>