React - Accessibility Deep Dive

Overview

Estimated time: 25–35 minutes

Go beyond essentials: implement a keyboard-trappable modal dialog and a roving tabindex toolbar.

Try it: Modal dialog with focus trap

View source
function useFocusTrap(active){
  const ref = React.useRef(null);
  React.useEffect(() => {
    if (!active) return;
    const root = ref.current; if (!root) return;
    const focusables = () => Array.from(root.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')).filter(el => !el.hasAttribute('disabled'));
    function onKey(e){
      if (e.key !== 'Tab') return;
      const els = focusables(); if (!els.length) return;
      const first = els[0], last = els[els.length-1];
      if (e.shiftKey && document.activeElement === first){ e.preventDefault(); last.focus(); }
      else if (!e.shiftKey && document.activeElement === last){ e.preventDefault(); first.focus(); }
    }
    root.addEventListener('keydown', onKey);
    const first = focusables()[0]; first?.focus();
    return () => root.removeEventListener('keydown', onKey);
  }, [active]);
  return ref;
}
function Modal({open, onClose}){
  const trapRef = useFocusTrap(open);
  if (!open) return null;
  return (
    <div role="dialog" aria-modal="true" aria-label="Demo modal" style={{position:'fixed', inset:0, background:'rgba(0,0,0,.3)', display:'grid', placeItems:'center'}} onClick={onClose}>
      <div ref={trapRef} onClick={e => e.stopPropagation()} style={{background:'#fff', padding:12, borderRadius:8, minWidth:260}}>
        <p>This is a modal. Tab is trapped inside.</p>
        <button onClick={onClose}>Close</button>
        <button onClick={() => alert('Action')} style={{marginLeft:8}}>Action</button>
      </div>
    </div>
  );
}
function App(){
  const [open, setOpen] = React.useState(false);
  return (<div>
    <button onClick={() => setOpen(true)}>Open modal</button>
    <Modal open={open} onClose={() => setOpen(false)} />
  </div>);
}
ReactDOM.createRoot(document.getElementById('try-a11y-deep')).render(<App />);

Try it: Roving tabindex toolbar

View source
function Roving(){
  const items = ['Bold','Italic','Underline','Code'];
  const [idx, setIdx] = React.useState(0);
  const refs = React.useRef([]);
  React.useEffect(() => { refs.current[idx]?.focus(); }, [idx]);
  function onKey(e){
    if (e.key === 'ArrowRight') setIdx((idx+1)%items.length);
    if (e.key === 'ArrowLeft') setIdx((idx-1+items.length)%items.length);
  }
  return (
    <div role="toolbar" aria-label="Formatting" onKeyDown={onKey}>
      {items.map((label,i) => (
        <button key={label} ref={el => refs.current[i]=el} tabIndex={i===idx?0:-1} aria-pressed={i===idx} onClick={() => setIdx(i)}>{label}</button>
      ))}
    </div>
  );
}
ReactDOM.createRoot(document.getElementById('try-roving')).render(<Roving />);

Syntax primer

  • Modal dialog: role="dialog" + aria-modal="true" and a focus trap to keep keyboard focus inside.
  • Roving tabindex: only the active item has tabIndex=0; others are -1 and reachable with arrow keys.

Vocabulary

  • Focus trap: keeps focus within a container until closed.
  • Roving tabindex: a pattern to manage focus among peers using arrow keys.

Common pitfalls

  • Forgetting to restore focus to the trigger button after closing the modal.
  • Not providing labels for the dialog content; use aria-label or aria-labelledby.

Exercises

  1. Return focus to the “Open modal” button on close.
  2. Add Escape key to close the modal and announce a status update in an aria-live region.
  3. Extend the roving toolbar to support Home/End keys to jump to first/last.

Checks for Understanding

  1. When should you use role="dialog" and aria-modal="true"?
  2. What’s the difference between a focus trap and a roving tabindex?
  3. How can you ensure keyboard focus returns to the trigger that opened a modal?
  4. Which keys typically move focus in a roving tabindex toolbar, and how can you extend support?
Answers
  1. Use them for modal dialogs that block interaction with the rest of the page until closed. role="dialog" identifies the widget and aria-modal="true" signals that other content is inert to assistive tech.
  2. A focus trap keeps tab focus inside a container (e.g., a modal). Roving tabindex manages which sibling element is tabbable, moving focus with arrow keys within a set (e.g., a toolbar).
  3. Store a ref to the opener (e.g., the button) and call focus() after closing the modal, typically in the onClose path or an effect that runs on open → false.
  4. Left/Right (or Up/Down depending on axis) move between items. Extend with Home/End to jump to first/last item, and optionally PageUp/PageDown for larger steps.

Checks for Understanding

  1. When should you use role="dialog" and aria-modal="true"?
  2. What’s the difference between a focus trap and a roving tabindex?
  3. How can you ensure keyboard focus returns to the trigger that opened a modal?
  4. Which keys typically move focus in a roving tabindex toolbar, and how can you extend support?
Answers
  1. Use them for modal dialogs that block interaction with the rest of the page until closed. role="dialog" identifies the widget and aria-modal="true" signals that other content is inert to assistive tech.
  2. A focus trap keeps tab focus inside a container (e.g., a modal). Roving tabindex manages which sibling element is tabbable, moving focus with arrow keys within a set (e.g., a toolbar).
  3. Store a ref to the opener (e.g., the button) and call focus() after closing the modal, typically in the onClose path or an effect that runs on open → false.
  4. Left/Right (or Up/Down depending on axis) move between items. Extend with Home/End to jump to first/last item, and optionally PageUp/PageDown for larger steps.