roving-tab-index

Overview

Overview

Section titled Overview

The RovingTabindexController is a reactive controller that implements the roving tabindex pattern, a key accessibility technique for managing keyboard navigation in composite widgets. This pattern allows multiple focusable elements to be represented by a single 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

Section titled 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 tabindex attributes 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

Section titled Usage

See it on NPM! How big is this package in your project?

yarn add @spectrum-web-components/reactive-controllers

Import the RovingTabindexController via:

import { RovingTabindexController } from '@spectrum-web-components/reactive-controllers/src/RovingTabindex.js';

Examples

Section titled Examples

Basic usage

Section titled 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

Section titled Configuration options

A Container can further customize the implementation of the RovingTabindexController with the following options:

  • direction to customize how and which arrow keys manage what element is to be focused and accepts a either a string of both, vertical, horizontal, or grid or a method returning one of those strings
  • elementEnterAction enacts actions other than focus on the entered element which accepts a method with a signature of (el: T) => void
  • elements provides the elements that will have their tabindex managed via a method with a signature of () => T[]
  • focusInIndex to control what element will recieve tabindex=0 while focus is outside of the Container and accepts a method with a signature of (_elements: T[]) => number
  • isFocusableElement describes the state an element much be in to receive focus via a method with a signature of (el: T) => boolean
  • listenerScope outlines 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

Section titled 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

Section titled 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

Section titled 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

Section titled 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

Section titled 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

Section titled Accessibility

The RovingTabindexController implements the W3C ARIA Authoring Practices Guide's roving tabindex pattern, which is essential for creating accessible composite widgets.

Why use roving tabindex?

Section titled 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:

  1. Including only one element in the tab order (tabindex="0")
  2. Making all other elements programmatically focusable (tabindex="-1")
  3. Managing arrow key navigation between elements
  4. Updating tabindex values as focus moves

ARIA roles and attributes

Section titled ARIA roles and attributes

When using the RovingTabindexController, ensure you apply appropriate ARIA roles and attributes:

For toolbars
<div role="toolbar" aria-label="Formatting tools" aria-orientation="horizontal">
    <!-- Managed elements -->
</div>
For tab lists
<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>
For listboxes
<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>
For Radiogroups
<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>
For menus
<div role="menu" aria-label="Actions" aria-orientation="vertical">
    <div role="menuitem">New</div>
    <div role="menuitem">Open</div>
</div>

Keyboard support

Section titled Keyboard support

The RovingTabindexController provides the following keyboard interactions:

Key Direction Mode Action Tab All Moves focus into or out of the composite widget (Right Arrow) horizontal, both, grid Moves focus to the next element (Left Arrow) horizontal, both, grid Moves focus to the previous element (Down Arrow) vertical, both, grid Moves focus to the next element (or down in grid) (Up Arrow) vertical, both, grid Moves focus to the previous element (or up in grid)

Disabled elements

Section titled Disabled elements

Important: According to WAI-ARIA Authoring Practices Guide, disabled items should remain focusable in these composite widgets:

  • 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

Section titled WCAG compliance

The roving tabindex pattern helps meet several WCAG success criteria:

  • 2.1.1 Keyboard (Level A) - All functionality is available from keyboard
  • 2.1.3 Keyboard (No Exception) (Level AAA) - No keyboard traps
  • 2.4.7 Focus Visible (Level AA) - Focus indicator is visible

References

Section titled References
  • ARIA Authoring Practices Guide - Roving tabindex
  • ARIA Authoring Practices Guide - Composite Widgets
  • WCAG 2.1 - Keyboard Accessible
Section titled Related components

The RovingTabindexController is used by these Spectrum Web Components:

  • <sp-tabs> - Tab navigation
  • <sp-radio-group> - Radio button groups
  • <sp-action-group> - Action button groups
  • <sp-menu> - Menu navigation
  • <sp-table> - Table keyboard navigation