EmpireUI
Get Pro
← Blog9 min read#keyboard navigation#react#accessibility

Keyboard Navigation in React: Arrow Keys, Home/End, Roving tabindex

Build keyboard-navigable React components the right way — roving tabindex, arrow key handling, Home/End, and focus management without breaking screen readers.

close-up of mechanical keyboard keys with colorful backlight illumination

Why Keyboard Navigation Still Trips Up Developers in 2026

Most devs think keyboard support means "just don't remove the outline." It's a start, but it's nowhere near enough. A properly keyboard-navigable UI means a user with no mouse can open a dropdown, move through its options with arrow keys, jump to the last item with End, and dismiss it with Escape — all without tabbing through every single focusable element on the page.

The WCAG 2.1 spec has required keyboard operability since 2018 and it's still one of the most commonly failed criteria in accessibility audits. That's not because it's obscure — it's because the browser's default tab-to-every-interactive-element model breaks down the moment you build composite widgets: listboxes, tab panels, toolbars, comboboxes, tree views, data grids.

In practice, the fix isn't complicated once you understand two patterns: roving tabindex and focus delegation. This article walks through both, with real React code you can drop in today. We'll also touch on the Home/End keys, which most tutorials skip entirely.

Worth noting: if you're pulling components off Empire UI, most of the interactive primitives already implement these patterns. But if you're building custom components or need to understand what's happening under the hood, read on.

The Problem with tabindex="0" on Everything

The naive approach to making a list of buttons navigable is sticking tabindex="0" on every item. That works — until the list has 50 items. Now a keyboard user has to Tab through all 50 before they can reach the next section of the page. That's not accessible, that's hostile.

The correct mental model is that a composite widget is a *single* tab stop. You Tab into it, then you navigate *within* it using arrow keys. Tab moves focus out entirely. This is the ARIA Authoring Practices Guide (APG) pattern for almost every widget type — listbox, menubar, radiogroup, toolbar.

// Wrong: every item is a tab stop
function BadList({ items }: { items: string[] }) {
  return (
    <ul role="listbox">
      {items.map((item) => (
        <li key={item} role="option" tabIndex={0}>
          {item}
        </li>
      ))}
    </ul>
  );
}

Tab your way through that and you'll see the problem immediately. The fix is roving tabindex — and it's simpler than the name suggests.

Roving tabindex: The Pattern You Actually Need

Roving tabindex works like this: only the *currently active* item has tabIndex={0}. Every other item gets tabIndex={-1}. When the user presses an arrow key, you programmatically move focus to the next item *and* update which item holds tabIndex={0}. The browser's native focus ring follows along, and Tab still moves cleanly in and out of the widget.

Here's a minimal implementation. The key insight is that you're managing two things independently: activeIndex (where the focus is) and calling .focus() imperatively on the target element. useRef with an array of refs is the cleanest way to handle this in React.

import { useRef, useState, KeyboardEvent } from 'react';

const ITEMS = ['Apple', 'Banana', 'Cherry', 'Durian', 'Elderberry'];

export function RovingList() {
  const [activeIndex, setActiveIndex] = useState(0);
  const itemRefs = useRef<(HTMLLIElement | null)[]>([]);

  function moveFocus(nextIndex: number) {
    setActiveIndex(nextIndex);
    itemRefs.current[nextIndex]?.focus();
  }

  function handleKeyDown(e: KeyboardEvent<HTMLUListElement>) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        moveFocus((activeIndex + 1) % ITEMS.length);
        break;
      case 'ArrowUp':
        e.preventDefault();
        moveFocus((activeIndex - 1 + ITEMS.length) % ITEMS.length);
        break;
      case 'Home':
        e.preventDefault();
        moveFocus(0);
        break;
      case 'End':
        e.preventDefault();
        moveFocus(ITEMS.length - 1);
        break;
    }
  }

  return (
    <ul role="listbox" onKeyDown={handleKeyDown}>
      {ITEMS.map((item, i) => (
        <li
          key={item}
          role="option"
          aria-selected={i === activeIndex}
          tabIndex={i === activeIndex ? 0 : -1}
          ref={(el) => { itemRefs.current[i] = el; }}
        >
          {item}
        </li>
      ))}
    </ul>
  );
}

Notice e.preventDefault() on every arrow key. That's non-optional. Without it, ArrowDown scrolls the page and Home jumps to the top of the document — not what you want when focus is inside a widget.

The modulo arithmetic on ArrowDown/ArrowUp gives you wrap-around behavior for free. If you don't want wrapping — say, for a toolbar — clamp to [0, items.length - 1] instead.

Home, End, and Why They're Always Forgotten

Honestly, Home and End are the most neglected keys in keyboard navigation implementations. I've audited dozens of design systems over the years and maybe 20% of them bother. But if you read ARIA APG — specifically the listbox, menu, and grid patterns — Home and End are *required* for the native widget keyboard contract.

The implementation is trivial once the roving tabindex is in place. HomemoveFocus(0). EndmoveFocus(items.length - 1). You already saw it in the code above. The harder question is: do you also support Ctrl+Home and Ctrl+End? For most widgets, plain Home/End is enough. For data grids with both rows and columns, you need to handle the cell grid navigation spec — that's a separate article.

Quick aside: for horizontal widgets like a toolbar or tab list, swap ArrowDown/ArrowUp for ArrowLeft/ArrowRight. The orientation of the widget determines which arrow axis is active. You can expose this in your component API as an orientation: 'horizontal' | 'vertical' prop and switch on it in the key handler.

function handleKeyDown(
  e: KeyboardEvent,
  orientation: 'horizontal' | 'vertical' = 'vertical'
) {
  const prev = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
  const next = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';

  switch (e.key) {
    case next:
      e.preventDefault();
      moveFocus((activeIndex + 1) % items.length);
      break;
    case prev:
      e.preventDefault();
      moveFocus((activeIndex - 1 + items.length) % items.length);
      break;
    case 'Home':
      e.preventDefault();
      moveFocus(0);
      break;
    case 'End':
      e.preventDefault();
      moveFocus(items.length - 1);
      break;
  }
}

Focus Management When Items Mount and Unmount

Static lists are easy. The real pain comes when items are added, removed, or filtered. If the focused item gets removed — say, a user filters a listbox and the currently focused option disappears — where does focus go? Into the void, typically. Screen reader announces nothing, the user is lost.

The rule: when the focused item is removed, move focus to the next item, or if there's no next, the previous item, or if the list is now empty, move focus to the container itself with a tabIndex={-1} on the <ul> and an explicit .focus() call. That last part matters — tabIndex={-1} makes an element programmatically focusable without putting it in the tab order.

useEffect(() => {
  // After filter, if activeIndex is out of bounds, clamp it
  if (activeIndex >= filteredItems.length) {
    const nextIndex = Math.max(0, filteredItems.length - 1);
    setActiveIndex(nextIndex);
    if (filteredItems.length === 0) {
      listRef.current?.focus(); // focus the container
    } else {
      itemRefs.current[nextIndex]?.focus();
    }
  }
}, [filteredItems.length]);

One more thing — when a modal or popover opens and contains a focusable widget, focus should move *into* the widget automatically. When it closes, focus must return to the trigger element. This is focus trapping and focus restoration, and it's separate from roving tabindex but equally important. The focus-management-react article covers that in detail.

If you're building on top of a headless library like Radix UI or React ARIA, most of this is handled for you. That said, knowing the underlying mechanics means you're not helpless when something breaks — and it will break in edge cases.

Extracting a Reusable useRovingTabindex Hook

Once you've written roving tabindex twice you'll want to extract it. Here's a hook that handles the core logic for any list-shaped widget. It returns the active index, a ref setter, and a key handler you attach to the container.

import { useRef, useState, useCallback, KeyboardEvent } from 'react';

interface UseRovingTabindexOptions {
  count: number;
  orientation?: 'vertical' | 'horizontal';
  loop?: boolean;
}

export function useRovingTabindex({
  count,
  orientation = 'vertical',
  loop = true,
}: UseRovingTabindexOptions) {
  const [activeIndex, setActiveIndex] = useState(0);
  const refs = useRef<(HTMLElement | null)[]>([]);

  const setRef = useCallback(
    (index: number) => (el: HTMLElement | null) => {
      refs.current[index] = el;
    },
    []
  );

  const moveFocus = useCallback(
    (nextIndex: number) => {
      const clamped = loop
        ? (nextIndex + count) % count
        : Math.max(0, Math.min(nextIndex, count - 1));
      setActiveIndex(clamped);
      refs.current[clamped]?.focus();
    },
    [count, loop]
  );

  const handleKeyDown = useCallback(
    (e: KeyboardEvent) => {
      const prev = orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
      const next = orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';

      if ([prev, next, 'Home', 'End'].includes(e.key)) {
        e.preventDefault();
      }

      switch (e.key) {
        case next:    moveFocus(activeIndex + 1); break;
        case prev:    moveFocus(activeIndex - 1); break;
        case 'Home':  moveFocus(0);               break;
        case 'End':   moveFocus(count - 1);       break;
      }
    },
    [activeIndex, count, orientation, moveFocus]
  );

  function getItemProps(index: number) {
    return {
      tabIndex: index === activeIndex ? 0 : -1,
      ref: setRef(index),
    };
  }

  return { activeIndex, handleKeyDown, getItemProps };
}

The getItemProps pattern is borrowed from Downshift and it's genuinely ergonomic — you spread the returned props onto each item and the hook wires everything up. Usage looks like this:

function Toolbar({ tools }: { tools: { label: string; icon: ReactNode }[] }) {
  const { handleKeyDown, getItemProps, activeIndex } = useRovingTabindex({
    count: tools.length,
    orientation: 'horizontal',
    loop: false,
  });

  return (
    <div role="toolbar" aria-label="Text formatting" onKeyDown={handleKeyDown}>
      {tools.map((tool, i) => (
        <button
          key={tool.label}
          aria-label={tool.label}
          aria-pressed={i === activeIndex}
          {...getItemProps(i)}
        >
          {tool.icon}
        </button>
      ))}
    </div>
  );
}

Look, you could reach for a library like @radix-ui/react-roving-focus for this and there's nothing wrong with that. But having your own 50-line hook means zero dependency overhead and full control when you need to deviate from the standard pattern. For projects where bundle size matters — and it always matters — that's worth something.

If you're building a whole component system, check out how the Empire UI interactive components handle focus management. There's a lot of nuance baked in, especially around the glassmorphism components that have overlay panels and nested focusable content.

Testing Keyboard Navigation Without Burning Time

The fastest first check: unplug your mouse and actually use the thing. Tab into the widget, press arrows, press Home/End, press Escape. That's 60 seconds and catches 80% of problems. No tooling required.

For automated coverage, @testing-library/user-event v14+ has a keyboard API that fires real keyboard events in the correct order, including keydown, keypress, and keyup. It's dramatically more realistic than fireEvent.keyDown.

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RovingList } from './RovingList';

test('arrow keys move focus through items', async () => {
  const user = userEvent.setup();
  render(<RovingList />);

  const firstItem = screen.getByRole('option', { name: 'Apple' });
  await user.click(firstItem); // establish initial focus

  await user.keyboard('{ArrowDown}');
  expect(screen.getByRole('option', { name: 'Banana' })).toHaveFocus();

  await user.keyboard('{End}');
  expect(screen.getByRole('option', { name: 'Elderberry' })).toHaveFocus();

  await user.keyboard('{Home}');
  expect(screen.getByRole('option', { name: 'Apple' })).toHaveFocus();
});

That test runs in under 100ms and gives you real confidence in the navigation contract. Pair it with a Playwright e2e test that actually fires keyboard events in a browser if you're building a UI library and shipping to others.

One final thought — keyboard navigation doesn't exist in isolation. It works alongside ARIA roles, live regions, and visible focus indicators. A tabIndex of 0 on an element with outline: none and no custom focus style is arguably *worse* than no keyboard support at all, because the user is stuck with no way to tell where focus is. The react-aria-guide article goes deeper on the ARIA side of this if you want the full picture.

FAQ

What's the difference between tabindex="0" and tabindex="-1"?

tabIndex={0} puts an element in the natural tab order so keyboard users reach it by pressing Tab. tabIndex={-1} removes it from the tab order but keeps it programmatically focusable via .focus() — essential for roving tabindex items that aren't currently active.

Should I use ArrowLeft/Right or ArrowUp/Down for navigation?

Match the visual orientation of your widget. Vertical lists use ArrowUp/Down. Horizontal toolbars and tab lists use ArrowLeft/Right. Some widgets like grids need all four, with Home/End operating on the current row and Ctrl+Home/End jumping to the first or last cell.

Does roving tabindex work with React virtualized lists?

It's trickier because items outside the viewport aren't in the DOM, so itemRefs.current[n]?.focus() silently fails. You need to scroll the item into view first, then focus it after the next render — a useEffect with the active index as a dependency handles this.

Can I just use a native <select> element instead of building a custom listbox?

Yes, and you probably should for simple single-select use cases. Native <select> gets keyboard navigation for free. Build a custom listbox only when you need custom option rendering, multi-select with checkboxes, or filtering — things the native element can't do.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Focus Management in React: Trap, Return and Programmatic FocusWCAG 2025 Accessibility Guide for React DevelopersCommand Palette in React: ⌘K Search, Keyboard Navigation, ARIADropdown Menu in React: Accessible, Animated, Keyboard-Ready