swjh
All posts

A script to move focus to the next or previous interactive element

⬪Updated: ⬪6 min read
a11yhtmljssnippet

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:

How it works

When called, moveFocus does the following:

  1. Collect focusable elements. It runs a single querySelectorAll with a list of selectors covering natively focusable element types: links, area links with href, buttons, inputs, selects, textareas, <summary>, media with controls, contenteditable regions, and elements with tabindex. By default, negative tabindex values are excluded to mirror sequential keyboard navigation, but setting includeNegativeTabIndex: true includes them for custom widgets.

  2. Filter to only reachable elements. Each match is checked: is it visible (via checkVisibility() or getClientRects()) 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.

  3. Find the active element. document.activeElement is looked up in the filtered list. If nothing in the list is focused, focus jumps to the first or last element depending on direction.

  4. Step one position. The index is incremented or decremented by one. If the boundary is hit and loop: true is set, it wraps around; otherwise the call is a no-op.

  5. Call .focus(). Applies focus directly to the target element, supporting options like preventScroll.

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:

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