Keyboard Navigation

Anything a pointer can do, a keyboard must do too. A native <button> is focusable, sits in the tab order, fires onClick for Enter and Space, and reports role=button. A <div onClick> does none of that: invisible to the tab order, silent to screen readers, dead to the keyboard. It is React 7,897 's most common accessibility bug.

tabIndex has three meaningful values: 0 joins the natural tab order, -1 is focusable by script only, and a positive number jumps ahead of everything, wrecking the order. A toolbar, menu, tab list or grid should be one tab stop, which is what -1 is for. The ARIA Authoring Practices pattern is the roving tabindex: one child holds tabIndex={0}, the rest -1, and arrow keys move state and DOM focus together — in React, an array of refs and one onKeyDown on the container.

A toolbar with a roving tabindexHTML
<script src="https://cdn.jsdelivr.net/npm/@babel/standalone@8.0.5/babel.min.js"></script>
<script type="importmap">{"imports":{"react":"https://esm.sh/react@19.3.0?dev",
"react-dom/client":"https://esm.sh/react-dom@19.3.0/client?dev"}}</script>
<div id="root"></div><script type="text/babel" data-type="module">
  import React, { useRef, useState } from 'react';
  import { createRoot } from 'react-dom/client';
  const ITEMS = ['Bold', 'Italic', 'Underline'];
  function Toolbar() {
    const [active, setActive] = useState(0), refs = useRef([]);
    function move(e) {
      const d = { ArrowRight: 1, ArrowLeft: -1 }[e.key];
      if (d === undefined) return;               // let Tab leave the toolbar
      e.preventDefault();
      const i = (active + d + ITEMS.length) % ITEMS.length;
      setActive(i); refs.current[i].focus();
    }
    return <div style={{ font: '16px system-ui', padding: 8 }}>
      <div role="toolbar" aria-label="Text formatting" onKeyDown={move}>
        {ITEMS.map((label, i) => <button key={label} ref={el => { refs.current[i] = el; }}
          tabIndex={i === active ? 0 : -1} style={{ font: 'inherit' }}>{label}</button>)}
      </div>
      <p>tabIndex {ITEMS.map((l, i) => `${l}=${i === active ? 0 : -1}`).join(', ')}</p>
    </div>;
  }
  createRoot(document.getElementById('root')).render(<Toolbar />);
  const press = () => document.activeElement.dispatchEvent(
    new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
  setTimeout(() => { document.querySelector('button').focus();
                     press(); setTimeout(press, 100); }, 600);
</script>
Browser output of Listing 2.116
Browser output of 116

The screenshot is taken after two scripted ArrowRight presses. Focus has moved to Underline, the only item left with tabIndex 0, so the next Tab leaves the toolbar instead of walking through it. Add Home and End, and aria-pressed if the buttons are toggles.

Keys belonging to the page rather than to a focused element — Escape, / to focus search — go on window inside an effect whose cleanup removes the listener. Forget the cleanup and you accumulate one listener per mount, which Strict Mode's double-invoked effects expose in development.