A script to move focus to the next or previous interactive element
A small, framework agnostic helper function that moves focus to the next or previous interactive element. Dependency free, so you can drop it into any project.
Disclaimer: This post assumes you have advanced knowledge of frontend development.
Keyboard users rely on focus moving predictably from one interactive element to the next. Sometimes the browser’s default tab order isn’t enough, and you need to move focus programmatically, for example inside a custom widget or after content changes.
Use cases
Moving focus to an adjacent element comes up more often than you might think:
- Removing or hiding a focusable element: deleting a list item, dismissing a chip or tag, closing an inline editor, or collapsing a panel, so focus doesn’t drop back to the body.
- Roving tabindex widgets: toolbars, menus, tabs, radio groups, tree views, and listboxes where arrow keys move focus between siblings.
- Custom arrow key navigation: grids, calendars, carousels, and galleries that should step focus with Left/Right or Up/Down.
- Reordering items: keeping focus on an item, or its neighbor, after moving it up or down a list.
- Keyboard shortcuts: stepping through feed items or table rows with keys like
jandk. - After async content swaps: the focused element got replaced by a re-render, so move to the closest one instead.
- Disabling a control mid-flow: if the focused element becomes disabled, shift focus so it isn’t lost.
How it works
When called, moveFocus does the following:
-
Collect focusable elements. It runs a single
querySelectorAllwith a list of selectors covering natively focusable element types: links, area links withhref, buttons, inputs, selects, textareas,<summary>, media with controls,contenteditableregions, and elements withtabindex. By default, negativetabindexvalues are excluded to mirror sequential keyboard navigation, but settingincludeNegativeTabIndex: trueincludes them for custom widgets. -
Filter to only reachable elements. Each match is checked: is it visible (via
checkVisibility()orgetClientRects()) and not inside an[inert]subtree?aria-disabled="true"elements are intentionally retained so screen reader and keyboard users can discover disabled items and read explanations. -
Find the active element.
document.activeElementis looked up in the filtered list. If nothing in the list is focused, focus jumps to the first or last element depending on direction. -
Step one position. The index is incremented or decremented by one. If the boundary is hit and
loop: trueis set, it wraps around; otherwise the call is a no-op. -
Call
.focus(). Applies focus directly to the target element, supporting options likepreventScroll.
Elements are kept in DOM order throughout. Positive tabindex values are not re-sorted because the spec discourages them and real usage is almost always 0 or -1.
The code: moveFocus.ts
Known limitations:
fieldset[disabled]: inputs inside a disabled<fieldset>are skipped by the browser’s native tab order, but will still appear in the collected list because only the element’s owndisabledattribute is checked. If your UI uses disabled fieldsets, add!el.closest('fieldset[disabled]')to the filter.opacity: 0elements: withcheckVisibility({ opacityProperty: true }), elements styledopacity: 0are treated as invisible and excluded. If you have intentionally transparent but interactive elements, you can dropopacityProperty: truefrom the call.- Positive
tabindexvalues: elements are collected in DOM order, so atabindex="2"element will not be promoted to the front of the list. Positive tabindex values are discouraged by the spec — stick to0and-1for predictable results.
For more details read @remarks in the code below.
Use with caution
Programmatically moving focus is powerful but easy to get wrong. If you’re not sure exactly what you’re doing, it’s often safer to leave focus alone and rely on the browser’s native behavior. Managing focus badly can trap users, skip important content, or make things worse than doing nothing. Always test thoroughly with real keyboard navigation and a screen reader, across browsers, before shipping.
Here you go, copy & paste this to your/project/helpers/moveFocus.ts:
/**
* Moves focus to the interactive element adjacent to the currently focused one.
*
* Collects visible, focusable elements (links, area links, buttons, inputs,
* selects, textareas, summaries, media with controls, editable content, and
* elements with tabindex) and shifts focus one step in the given direction.
*
* @param direction - Direction to move focus: `"next"` or `"previous"`.
* Defaults to `"next"`.
* @param options - Optional settings.
* @param options.loop - Wrap around to the other end when reaching the
* boundary. Defaults to `false`.
* @param options.container - Root element to search within. Defaults to the
* whole `document`.
* @param options.includeNegativeTabIndex - Include elements with negative
* tabindex (e.g., `tabindex="-1"` items inside roving tabindex widgets).
* Defaults to `false`.
* @param options.preventScroll - Passed to `.focus()`. Prevents the browser
* from scrolling the element into view. Defaults to `false`.
* @returns Nothing. No-op if there are no focusable elements or if the
* boundary is reached without `loop` enabled.
* @remarks
* - Tab order: elements are gathered in DOM order, so positive `tabindex`
* values are not respected. Stick to `tabindex="0"`/`"-1"` for predictable
* results. The function intentionally avoids re-sorting by tabindex because
* the spec discourages positive tabindex values and real-world usage is
* almost exclusively 0 / -1.
* - Accessible disabled pattern: `aria-disabled="true"` elements are
* intentionally kept focusable so screen reader and keyboard users can
* discover them and read explanations (e.g. via `aria-describedby`).
* - Roving tabindex: By default, elements with negative tabindex are excluded
* (matching sequential tab navigation). Pass `includeNegativeTabIndex: true`
* when navigating inside custom widgets like toolbars, tabs, or menus.
* - Visibility: uses `checkVisibility()` when available (catches
* `display: none`, `visibility: hidden`, `content-visibility`, and more),
* falling back to `getClientRects().length` in older browsers.
* `checkVisibility({ opacityProperty: true })` will also exclude elements
* with `opacity: 0`. If you have intentionally transparent-but-interactive
* elements, pass `opacityProperty: false` or omit the option.
* - `fieldset[disabled]`: inputs nested inside a disabled `<fieldset>` are
* excluded from the native tab order, but this function's `:not([disabled])`
* selectors only check the element's own `disabled` attribute. Those inputs
* will still appear in the collected list. If your UI uses disabled
* fieldsets, add an extra filter: `!el.closest('fieldset[disabled]')`.
*/
export interface MoveFocusOptions {
loop?: boolean;
container?: HTMLElement | Document | null;
includeNegativeTabIndex?: boolean;
preventScroll?: boolean;
}
export function moveFocus(
direction: "next" | "previous" = "next",
options: MoveFocusOptions = {},
): void {
const notNegativeTabindex = options.includeNegativeTabIndex
? ""
: ":not([tabindex^='-'])";
// Selectors matching elements that are natively focusable or opted in.
const focusableSelectors = [
`a[href]${notNegativeTabindex}`,
`area[href]${notNegativeTabindex}`,
`button:not([disabled])${notNegativeTabindex}`,
`input:not([disabled]):not([type=hidden])${notNegativeTabindex}`,
`select:not([disabled])${notNegativeTabindex}`,
`textarea:not([disabled])${notNegativeTabindex}`,
`summary${notNegativeTabindex}`,
`audio[controls]${notNegativeTabindex}`,
`video[controls]${notNegativeTabindex}`,
`[contenteditable]:not([contenteditable=false])${notNegativeTabindex}`,
options.includeNegativeTabIndex ? "[tabindex]" : "[tabindex]:not([tabindex^='-'])",
];
// Scope the search to the given container, or the whole document.
const container = options.container || document;
// Reports whether an element is actually rendered/visible. Prefers the
// modern checkVisibility() API and falls back to getClientRects() length.
const isVisible = (el: HTMLElement): boolean =>
typeof el.checkVisibility === "function"
? el.checkVisibility({ opacityProperty: true, visibilityProperty: true })
: el.getClientRects().length > 0;
// Gather matches, keeping only ones that are visible and not inert.
// Native [disabled] is excluded by selector. aria-disabled="true" is kept
// focusable following the accessible disabled pattern.
const focusableElements: HTMLElement[] = Array.from(
container.querySelectorAll<HTMLElement>(focusableSelectors.join(",")),
).filter((el) => isVisible(el) && !el.closest("[inert]"));
if (focusableElements.length === 0) return;
// Find where the currently focused element sits in the list.
const activeElement = document.activeElement as HTMLElement;
const currentIndex = focusableElements.indexOf(activeElement);
// Nothing focused (or focus is outside the list): start at the appropriate
// end so the first step lands on a sensible element in either direction.
if (currentIndex === -1) {
focusableElements[
direction === "next" ? 0 : focusableElements.length - 1
]?.focus({ preventScroll: options.preventScroll });
return;
}
let targetIndex = currentIndex;
if (direction === "next") {
// Step forward, wrapping to the start only when looping is enabled.
targetIndex = currentIndex + 1;
if (targetIndex >= focusableElements.length) {
if (options.loop) {
targetIndex = 0;
} else {
return;
}
}
} else {
// Step backward, wrapping to the end only when looping is enabled.
targetIndex = currentIndex - 1;
if (targetIndex < 0) {
if (options.loop) {
targetIndex = focusableElements.length - 1;
} else {
return;
}
}
}
// Move focus to the resolved target, if one exists.
focusableElements[targetIndex]?.focus({ preventScroll: options.preventScroll });
}↑ By using this you hereby swear that you read the post and understand what you’re doing.
Usage examples
Delete a list item and move focus to the previous sibling:
deleteButton.addEventListener("click", () => {
moveFocus("previous"); // step before removing, so the target still exists
listItem.remove();
});First-item edge case: if the deleted item was the first in the list, moveFocus("previous") will be a no-op (no previous sibling). In that case you need a fallback — move focus to the list container or a relevant heading instead. Roselli’s priority order: previous equivalent control → grouping container → nearest landmark. Never let focus fall back to <body>.
Arrow key navigation inside a toolbar:
toolbar.addEventListener("keydown", (e) => {
if (e.key === "ArrowRight")
moveFocus("next", {
container: toolbar,
loop: true,
includeNegativeTabIndex: true,
});
if (e.key === "ArrowLeft")
moveFocus("previous", {
container: toolbar,
loop: true,
includeNegativeTabIndex: true,
});
});j / k shortcuts to step through feed items:
myList.addEventListener("keydown", (e) => {
if (e.key === "j") moveFocus("next");
if (e.key === "k") moveFocus("previous");
});Recover focus after a re-render replaces the focused element:
// call after the DOM update; focus fell back to body
moveFocus("next");Further reading
- Adrian Roselli: Where to Put Focus When Deleting a Thing — when deleting something, move focus to the prior equivalent control or its grouping container.
- Hidde de Vries: Using JavaScript to trap focus in an element — the canonical reference for manual Tab/Shift+Tab focus trapping; relevant when
inertisn’t sufficient (e.g. shadow DOM). - Manuel Matuzović on SitePoint: Writing JavaScript with Accessibility in Mind — covers
blurevent side-effects of programmatic focus moves and other JS a11y patterns. - Web Accessibility Cookbook by Manuel Matuzović, especially chapter 6
- WAI-ARIA Authoring Practices: Keyboard Navigation Inside Components — the authoritative guide to roving tabindex, focus traps, and keyboard interaction patterns.
- WAI-ARIA Authoring Practices: Design Patterns — pattern-by-pattern focus behaviour specs for menus, dialogs, grids, tabs, and more.
- MDN: HTMLElement: focus() method —
.focus()options includingpreventScroll. - MDN:
checkVisibility()— full option reference and browser compatibility table.