Skip to content

Instantly share code, notes, and snippets.

@MrGrigri
Last active February 19, 2026 04:14
Show Gist options
  • Select an option

  • Save MrGrigri/9b5e96b5da9f2a6554da0cd5c9bf60ce to your computer and use it in GitHub Desktop.

Select an option

Save MrGrigri/9b5e96b5da9f2a6554da0cd5c9bf60ce to your computer and use it in GitHub Desktop.
Handle roving tab index.

Roving Tab Index

These utilities are available to help maintain a roving tab index. This conforms to the Aria Authoring Practices

Keyboard interactions using KeyboardEvent: key property:

Key Orientation Effect
ArrowUp horizontal No effect
ArrowUp vertical Moves to the previous element
ArrowLeft horizontal Moves to the previous element
Arrowleft vertical No effect
ArrowDown horizontal No effect
ArrowDown vertical Moves to the next element
ArrowRight horizontal Moves to the next element
ArrowRight vertical No effect
Home both Moves to first element
End both Moves to last element

Usage

By default, the roving index wrapps and utilizes both orientations.

import { handleRovingIndex } from 'path/to/index.ts';

const containingElement = document.querySelector('#element');
const removeRovingIndex = handleRovingIndex(containingElement);

// Cleanup callback function when needed to remove event listeners
removeRovingIndex();

Options

Name Possible values Exported or Built-in Type Default value
orientation 'horizontal' | 'vertical' | 'both' Orientation ORIENTATION.BOTH
wrap true | false boolean true
customSelector string string See get-focusable-elements.ts file
useMemory true | false boolean true
event 'keydown' | 'keyup' KeyboardEventType EVENTS.KEYDOWN
skipDisabled true | false boolean false
import { handleRovingIndex, ORIENTATION, EVENTS, type HandleRovingIndexOptions } from 'path/to/index.ts';

const containingElement = document.querySelector('#element');
const rovingIndexOptions: HandleRovingIndexOptions = {
  orientation: ORIENTATION.VERTICAL,
  wrap: false,
  customSelector: ':scope(li > :is(a, button))',
  useMemory: false,
  event: EVENTS.KEYUP,
  skipDisabled: fakse
}

handleRovingIndex(containingElement);
export type GetFocusableElements = (
container: HTMLElement,
customSelector?: string,
) => HTMLElement[];
/**
* Get all focusable elements within a given parent element, excluding those that are disabled or hidden
*
* @example
* import { getFocusableElements } from './get-focusable-elements';
* const container = document.getElementById('my-container');
* const focusableElements = getFocusableElements(container);
*
* @param parentElement - Containing element
* @param customSelector - Optional custom selector to specify which elements are considered focusable
* @returns Array of HTML Elements that can be focused
*/
export const getFocusableElements: GetFocusableElements = (parentElement, customSelector) => {
const BASE_SELECTORS = [
'a[href]',
'button',
'input',
'select',
'summary',
'textarea',
'[tabindex]',
'[contenteditable]',
].join(',');
const selectors = customSelector || BASE_SELECTORS;
return Array.from(parentElement.querySelectorAll<HTMLElement>(selectors));
};
import type { Directions } from './index.types';
import { ATTRIBUTES, DIRECTIONS } from './index.constants';
export type GetRelativeFocusableElementOptions = {
skipDisabled?: boolean;
wrap?: boolean;
};
export type GetRelativeFocusableElement = (
elements: HTMLElement[],
currentIndex: number,
direction: Directions,
options?: GetRelativeFocusableElementOptions,
) => HTMLElement | null;
export const defaultOptions: GetRelativeFocusableElementOptions = {
skipDisabled: true,
wrap: true,
};
/**
* @Finds the next focusable element in a list of elements based on the current index and direction.
*
* @example
* import { getRelativeFocusableElement } from './get-relative-focusable-element';
*
* const focusableElements = document.querySelectorAll('ul > li > is:(button, a)')
* const currentIndex = 0;
* const nextElement = getRelativeFocusableElement(focusableElements, currentIndex, 'next');
*
* @param elements - List of elements to parse
* @param currentIndex - Current index in the list of elements
* @param direction - Direction to move the list
* @param options - Additional options for finding the next focusable element
* @returns - The next focusable element based on the current index and direction, or null if none found
*/
export const getRelativeFocusableElement: GetRelativeFocusableElement = (
elements,
currentIndex,
direction,
options,
) => {
options = { ...defaultOptions, ...options };
const offset = direction === DIRECTIONS.NEXT ? 1 : direction === DIRECTIONS.PREVIOUS ? -1 : 0;
if (currentIndex < 0 || currentIndex >= elements.length) {
return null;
}
if (!options.wrap) {
let newIndex = currentIndex + offset;
while (newIndex >= 0 && newIndex < elements.length) {
const element = elements[newIndex];
if (!options.skipDisabled || !element.hasAttribute(ATTRIBUTES.DISABLED)) {
return element;
}
newIndex += offset;
}
return null;
}
for (let i = 1; i <= elements.length; i++) {
const newIndex =
(((currentIndex + i * offset) % elements.length) + elements.length) % elements.length;
const element = elements[newIndex];
const isDisabled = !options.skipDisabled || !element.hasAttribute(ATTRIBUTES.DISABLED);
if (isDisabled) {
return element;
}
}
return null;
};
import type { Directions, Orientation, KeyboardEventType } from './index.types';
import { getFocusableElements } from './get-focusable-elements';
import { ATTRIBUTES, DIRECTIONS, EVENTS, KEYS, ORIENTATION } from './index.constants';
import { getRelativeFocusableElement } from './get-relative-focusable-element';
export type HandleRovingIndexOptions = {
/** @desc Orientation of the roving index */
orientation?: Orientation;
/** @desc Whether to wrap around when reaching the end of the list */
wrap?: boolean;
/** @desc Custom selector to use for focusable elements */
customSelector?: string;
/** @desc Whether to use memory to track the last focused element */
useMemory?: boolean;
/** @desc Which event to use */
event?: KeyboardEventType;
/** @desc Whether to skip disabled elements */
skipDisabled?: boolean;
};
export type HandleRovingIndexReturn = () => void;
export type HandleRovingIndex = (
parentElement: HTMLElement,
options?: HandleRovingIndexOptions,
) => HandleRovingIndexReturn;
export const defaultOptions: HandleRovingIndexOptions = {
orientation: ORIENTATION.BOTH,
wrap: true,
useMemory: true,
event: EVENTS.KEYDOWN,
skipDisabled: true,
};
/**
* Adds roving tabindex functionality to a list of elements.
*
* @example
* const container = document.getElementById('my-container');
* handleRovingIndex(container, { orientation: 'horizontal', wrap: true });
*
* @param {HTMLElement} parentElement - The parent element containing focusable children.
* @param {HandleRovingIndexOptions} options - Configuration options for roving index behavior.
* @return A cleanup function to remove the event listeners when no longer needed.
*/
export const handleRovingIndex: HandleRovingIndex = (parentElement, options) => {
options = { ...defaultOptions, ...options };
const elements = getFocusableElements(parentElement, options?.customSelector);
const handleKeyDown = (event: KeyboardEvent, index: number) => {
let elementToFocus: HTMLElement | null = null;
let getNextOrPrevious: Directions = undefined;
switch (options.orientation) {
case ORIENTATION.HORIZONTAL:
if (event.key === KEYS.ARROW_RIGHT) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.NEXT;
} else if (event.key === KEYS.ARROW_LEFT) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.PREVIOUS;
}
break;
case ORIENTATION.VERTICAL:
if (event.key === KEYS.ARROW_DOWN) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.NEXT;
} else if (event.key === KEYS.ARROW_UP) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.PREVIOUS;
}
break;
case ORIENTATION.BOTH:
default:
if (event.key === KEYS.ARROW_RIGHT || event.key === KEYS.ARROW_DOWN) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.NEXT;
} else if (event.key === KEYS.ARROW_LEFT || event.key === KEYS.ARROW_UP) {
event.preventDefault();
getNextOrPrevious = DIRECTIONS.PREVIOUS;
}
break;
}
if (event.key === KEYS.HOME) {
if (options.skipDisabled) {
for (let i = 0; i < elements.length; i++) {
if (!elements[i].hasAttribute(ATTRIBUTES.DISABLED)) {
elementToFocus = elements[i];
break;
}
}
} else {
elementToFocus = elements[0];
}
} else if (event.key === KEYS.END) {
if (options.skipDisabled) {
for (let i = elements.length - 1; i >= 0; i--) {
if (!elements[i].hasAttribute(ATTRIBUTES.DISABLED)) {
elementToFocus = elements[i];
break;
}
}
} else {
elementToFocus = elements[elements.length - 1];
}
} else if (getNextOrPrevious) {
elementToFocus = getRelativeFocusableElement(elements, index, getNextOrPrevious, {
skipDisabled: options.skipDisabled,
wrap: options.wrap,
});
} else {
return;
}
elementToFocus?.focus();
if (options.useMemory) {
elements.forEach((el) => el.setAttribute(ATTRIBUTES.TABINDEX, '-1'));
elementToFocus?.setAttribute(ATTRIBUTES.TABINDEX, '0');
}
};
let firstElementSet = false;
elements.forEach((el, index) => {
const isDisabled = options.skipDisabled && el.hasAttribute(ATTRIBUTES.DISABLED);
if (!firstElementSet && !isDisabled) {
el.setAttribute(ATTRIBUTES.TABINDEX, '0');
firstElementSet = true;
} else {
el.setAttribute(ATTRIBUTES.TABINDEX, '-1');
}
el.addEventListener(options.event || EVENTS.KEYDOWN, (event) =>
handleKeyDown(event as KeyboardEvent, index),
);
});
return () => {
elements.forEach((el, index) =>
el.removeEventListener(options.event || EVENTS.KEYDOWN, (event) =>
handleKeyDown(event as KeyboardEvent, index),
),
);
};
};
export const DIRECTIONS = {
NEXT: 'next',
PREVIOUS: 'previous',
} as const;
export const ORIENTATION = {
HORIZONTAL: 'horizontal',
VERTICAL: 'vertical',
BOTH: 'both',
} as const;
export const KEYS = {
ARROW_UP: 'ArrowUp',
ARROW_DOWN: 'ArrowDown',
ARROW_LEFT: 'ArrowLeft',
ARROW_RIGHT: 'ArrowRight',
HOME: 'Home',
END: 'End',
} as const;
export const EVENTS = {
KEYDOWN: 'keydown',
KEYUP: 'keyup',
} as const;
export const ATTRIBUTES = {
TABINDEX: 'tabindex',
DISABLED: 'disabled',
} as const;
export {
// Main Imports
getFocusableElements,
// Type Imports
type GetFocusableElements,
} from './get-focusable-elements';
export {
// Main Imports
getRelativeFocusableElement,
// Type Imports
type GetRelativeFocusableElement,
type GetRelativeFocusableElementOptions,
} from './get-relative-focusable-element';
export {
// Main Imports
handleRovingIndex,
// Type Imports
type HandleRovingIndexOptions,
type HandleRovingIndexReturn,
} from './handle-roving-index';
export {
// Constant Imports
ATTRIBUTES,
DIRECTIONS,
EVENTS,
KEYS,
ORIENTATION,
} from './index.constants';
export type {
// Type Imports
Directions,
KeyboardEventType,
Keys,
Orientation,
} from './index.types';
import { DIRECTIONS, EVENTS, KEYS, ORIENTATION } from './index.constants';
export type Directions = (typeof DIRECTIONS)[keyof typeof DIRECTIONS] | undefined;
export type Keys = (typeof KEYS)[keyof typeof KEYS];
export type Orientation = (typeof ORIENTATION)[keyof typeof ORIENTATION];
export type KeyboardEventType = (typeof EVENTS)[keyof typeof EVENTS];

Comments are disabled for this gist.