A11Y Solutions
Provides auto-suggestions when entering text

    Roving Focus

    Also known as "Roving tabindex", typically reserved for custom UI components that require manual focus management using tabindex.

    The Problem

    Custom components and complex interfaces do not automatically benefit from built in ARIA roles, states, and properties like existing HTML tags. This leads to users relying on keyboard and screen reader navigation being unable to properly navigate the component.

    The Solution

    Using native HTML elements is preferred, but there are rare cases where that's not possible. In such cases it's imperative to apply the ARIA roles, states, and properties necessary to allow for seamless interaction regardless of input device. Follow the recommendations for the most relevant pattern or widget found in ARIA Authoring Practices Guide Patterns.

    Related Articles

    Live Examples

    Before illustrates the problem, After illustrates the solution. Click the header to see it larger in a modal.

    Please note that this Before example contains inaccessible code, use this link to skip to the After Live Example

    before

    after

    Code Comparison

    Code diff between the before and after examples above to show the changes necessary. To copy the final source click on the 'after' path link before the diff.

    source

    Comparing /examples/focus/roving-focus/before/index.html to /examples/focus/roving-focus/after/index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Accessibility Solutions - Focus - Roving Focus</title>
    <link rel="stylesheet" href="../../../presentation.css" />
    <link rel="stylesheet" href="roving-focus.css" />
    </head>
    <body>
    <h3
    id="drink-options">Drink Options</h3>
    <ul

    id="group1"
    class="radiogroup"
    role="radiogroup"
    aria-labelledby="drink-options"
    >
    <li tabindex="0" class="radio"
    role="radio" checked>Water</li>
    <li tabindex="-1" class="radio"
    role="radio">Tea</li>
    <li tabindex="-1" class="radio"
    role="radio">Coffee</li>
    <li tabindex="-1" class="radio"
    role="radio">Cola</li>
    <li tabindex="-1" class
    ="radio" role="radio">Ginger Ale</li>
    </ul>
    <script src="roving-focus.js"></script>
    </body>
    </html>

    styles

    Comparing /examples/focus/roving-focus/before/roving-focus.css to /examples/focus/roving-focus/after/roving-focus.css

    .radiogroup {
    list-style: none;
    }

    .radio {
    position: relative;
    }

    .radio::before {
    content: '';
    display: block;
    width: 10px;
    height: 10px;
    border: 1px solid black;
    position: absolute;
    left: -18px;
    top: 3px;
    border-radius: 50%;
    }

    .radio[checked]::after {
    content: '';
    display: block;
    width: 8px;
    height: 8px;
    background: red;
    position: absolute;
    left: -16px;
    top: 5px;
    border-radius: 50%;
    }

    javascript

    Comparing /examples/focus/roving-focus/before/roving-focus.js to /examples/focus/roving-focus/after/roving-focus.js

    (function () {
    'use strict';

    // Helper function to convert NodeLists to Arrays
    function slice(nodes) {
    return Array.prototype.slice.call(nodes);
    }

    function RadioGroup(id) {
    this.el = document.querySelector(id);
    this.buttons = slice(this.el.querySelectorAll('.radio'));
    this.selected = 0;
    this.focusedButton = this.buttons[this.selected];

    this.el.addEventListener('keydown', this.handleKeyDown.bind(this));
    this.el.addEventListener('click', this.handleClick.bind(this));
    }

    RadioGroup.prototype.handleKeyDown = function (e) {
    switch (e.key) {
    case 'ArrowUp':
    case 'ArrowLeft': {
    e.preventDefault();

    if (this.selected === 0) {
    this.selected = this.buttons.length - 1;
    } else {
    this.selected--;
    }
    break;
    }

    case 'ArrowDown':
    case 'ArrowRight': {
    e.preventDefault();

    if (this.selected === this.buttons.length - 1) {
    this.selected = 0;
    } else {
    this.selected++;
    }
    break;
    }
    }
    this.changeFocus(this.selected);
    };

    RadioGroup.prototype.handleClick = function (e) {
    const children = e.target.parentNode.children;
    for (let i = 0; i < children.length; i++) {
    if (e.target === children[i]) break;
    }
    this.selected = i;
    this.changeFocus(this.selected);
    };

    RadioGroup.prototype.changeFocus = function (idx) {
    // Set the old button to tabindex -1
    this.focusedButton.tabIndex = -1;
    this.focusedButton.removeAttribute('checked');

    // Set the new button to tabindex 0 and focus it
    this.focusedButton = this.buttons[idx];
    this.focusedButton.tabIndex = 0;
    this.focusedButton.focus();
    this.focusedButton.setAttribute('checked', 'checked');
    };

    const group1 = new RadioGroup('#group1');
    })();