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
- Return focus to the “Open modal” button on close.
- Add Escape key to close the modal and announce a status update in an aria-live region.
- Extend the roving toolbar to support Home/End keys to jump to first/last.
Checks for Understanding
- When should you use role="dialog" and aria-modal="true"?
- What’s the difference between a focus trap and a roving tabindex?
- How can you ensure keyboard focus returns to the trigger that opened a modal?
- Which keys typically move focus in a roving tabindex toolbar, and how can you extend support?
Answers
- 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.
- 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).
- 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.
- 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
- When should you use role="dialog" and aria-modal="true"?
- What’s the difference between a focus trap and a roving tabindex?
- How can you ensure keyboard focus returns to the trigger that opened a modal?
- Which keys typically move focus in a roving tabindex toolbar, and how can you extend support?
Answers
- 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.
- 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).
- 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.
- 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.