Skip to content

Instantly share code, notes, and snippets.

@MrGrigri
Last active February 10, 2026 15:37
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));
};
export type GetNextFocusableElement = (
elements: HTMLElement[],
currentIndex: number,
options?: GetNextFocusableElementOptions,
) => HTMLElement | null;
export type GetNextFocusableElementOptions = {
skipDisabled?: boolean;
wrap?: boolean;
};
/**
* Gets the next focusable element in a list of elements, skipping disabled or non-focusable ones.
*
* @example
* const elements = [button1, button2, button3];
* const currentIndex = 0; // Index of button1
* const nextElement = getNextFocusableElement(elements, currentIndex);
* // nextElement will be button2 if it's focusable, otherwise button3 or null.
*/
export const getNextFocusableElement: GetNextFocusableElement = (
elements,
currentIndex,
options,
) => {
const skipDisabledOption = options?.skipDisabled ?? true;
if (currentIndex < 0 || currentIndex >= elements.length) {
return null;
}
let nextIndex = currentIndex + 1;
if (nextIndex >= elements.length && options?.wrap) {
nextIndex = 0;
}
if (nextIndex < 0 || nextIndex >= elements.length) {
return null;
}
const element = elements[nextIndex];
if (skipDisabledOption && element.hasAttribute('disabled')) {
return getNextFocusableElement(elements, nextIndex, options);
}
return element;
};
export type GetPreviousFocusableElement = (
elements: HTMLElement[],
currentIndex: number,
options?: GetPreviousFocusableElementOptions,
) => HTMLElement | null;
export type GetPreviousFocusableElementOptions = {
skipDisabled?: boolean;
wrap?: boolean;
};
/**
* Gets the previous focusable element in a list of elements, skipping disabled or non-focusable ones.
*
* @example
* const elements = [button1, button2, button3];
* const currentIndex = 2; // Index of button3
* const prevElement = getPreviousFocusableElement(elements, currentIndex);
* // prevElement will be button2 if it's focusable, otherwise button1 or null.
*/
export const getPreviousFocusableElement: GetPreviousFocusableElement = (
elements,
currentIndex,
options,
) => {
const skipDisabledOption = options?.skipDisabled ?? true;
if (currentIndex < 0 || currentIndex >= elements.length) {
return null;
}
let prevIndex = currentIndex - 1;
if (prevIndex < 0 && options?.wrap) {
prevIndex = elements.length - 1;
}
if (prevIndex < 0 || prevIndex >= elements.length) {
return null;
}
const element = elements[prevIndex];
if (skipDisabledOption && element.hasAttribute('disabled')) {
return getPreviousFocusableElement(elements, prevIndex, options);
}
return element;
};
import { getFocusableElements } from './get-focusable-elements';
import { getNextFocusableElement } from './get-next-focusable-element';
import { getPreviousFocusableElement } from './get-previous-focusable-element';
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;
const EVENTS = {
KEYDOWN: 'keydown',
KEYUP: 'keyup',
} as const;
export type Direction = (typeof DIRECTIONS)[keyof typeof DIRECTIONS] | undefined;
export type Orientation = (typeof ORIENTATION)[keyof typeof ORIENTATION];
export type Keys = (typeof KEYS)[keyof typeof KEYS];
export type KeyboardEventType = (typeof EVENTS)[keyof typeof EVENTS];
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;
/**
* 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) => {
const wrapOption = options?.wrap ?? true;
const orientationOption = options?.orientation ?? ORIENTATION.BOTH;
const eventOption = options?.event ?? EVENTS.KEYDOWN;
const useMemoryOption = options?.useMemory ?? true;
const skipDisabledOption = options?.skipDisabled ?? true;
const elements = getFocusableElements(parentElement, options?.customSelector);
const handleKeyDown = (event: KeyboardEvent, index: number) => {
let elementToFocus: HTMLElement | null = null;
let getNextOrPrevious: Direction = undefined;
switch (orientationOption) {
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 (skipDisabledOption) {
for (let i = 0; i < elements.length; i++) {
if (!elements[i].hasAttribute('disabled')) {
elementToFocus = elements[i];
break;
}
}
} else {
elementToFocus = elements[0];
}
} else if (event.key === KEYS.END) {
if (skipDisabledOption) {
for (let i = elements.length - 1; i >= 0; i--) {
if (!elements[i].hasAttribute('disabled')) {
elementToFocus = elements[i];
break;
}
}
} else {
elementToFocus = elements[elements.length - 1];
}
} else if (getNextOrPrevious) {
elementToFocus =
getNextOrPrevious === DIRECTIONS.NEXT
? getNextFocusableElement(elements, index, {
wrap: wrapOption,
skipDisabled: skipDisabledOption,
})
: getPreviousFocusableElement(elements, index, {
wrap: wrapOption,
skipDisabled: skipDisabledOption,
});
} else {
return;
}
elementToFocus?.focus();
if (useMemoryOption) {
elements.forEach((el) => el.setAttribute('tabindex', '-1'));
elementToFocus?.setAttribute('tabindex', '0');
}
};
let firstElementSet = false;
elements.forEach((el, index) => {
const isDisabled = skipDisabledOption && el.hasAttribute('disabled');
if (!firstElementSet && !isDisabled) {
el.setAttribute('tabindex', '0');
firstElementSet = true;
} else {
el.setAttribute('tabindex', '-1');
}
el.addEventListener(eventOption, (event) => handleKeyDown(event as KeyboardEvent, index));
});
return () => {
elements.forEach((el, index) =>
el.removeEventListener(eventOption, (event) => handleKeyDown(event as KeyboardEvent, index)),
);
};
};
export {
getFocusableElements,
type GetFocusableElements,
} from './get-focusable-elements';
export {
getNextFocusableElement,
type GetNextFocusableElementOptions,
type GetNextFocusableElement,
} from './get-next-focusable-element';
export {
getPreviousFocusableElement,
type GetPreviousFocusableElementOptions,
type GetPreviousFocusableElement,
} from './get-previous-focusable-element';
export {
handleRovingIndex,
DIRECTIONS,
KEYS,
ORIENTATION,
type Direction,
type HandleRovingIndexOptions,
type HandleRovingIndexReturn,
type Keys,
type Orientation,
type KeyboardEventType,
} from './handle-roving-index';

Comments are disabled for this gist.