Focus lives on a DOM node, and React 7,897 deletes DOM nodes for a living. Close a panel, filter a list, change route, and the focused element disappears; the browser drops focus onto <body>, so the next Tab starts again from the top of the page. Mouse users never notice; everyone else loses their place.
Three moments need handling: focus into an overlay, focus back to whatever opened it, and focus onto the new heading after a route change. The first two are free if you render a native <dialog> and call showModal() in an effect: that traps Tab, wires Escape to close, inerts the page behind it, and restores focus on close. <dialog open> gives a non-modal dialog with none of it.
function Rename({ open, onClose }) {
const ref = useRef(null), field = useRef(null);
useEffect(() => {
if (!open) return;
ref.current.showModal(); // traps Tab, handles Escape, inerts the page
field.current.focus(); // put focus where the user has to act
return () => ref.current?.close();
}, [open]);
return <dialog ref={ref} aria-labelledby="t" onClose={onClose}>
<h2 id="t">Rename file</h2>
<label htmlFor="n">New name</label> <input id="n" ref={field} defaultValue="notes.md" />
<button onClick={onClose}>Save</button> <button onClick={onClose}>Cancel</button>
</dialog>;
}Dump the tree while that dialog is open and it holds one subtree — dialog "Rename file" with its heading, textbox and buttons. The button that opened it is still in the DOM but gone from the tree, so nothing behind the overlay is reachable.
For overlays you cannot build on <dialog> — a command palette, a combobox popup, a portal drawer — use a library instead of writing the trap: focus-trap-react 12.0.3 and react-focus-lock 2.13.7 (both MIT), @radix-ui/react-dialog 1.1.23 (MIT), or react-aria-components 1.21.1 (Apache-2.0).
On a route change, give the new <h1> tabIndex={-1} and focus() it in an effect: focus moves without the heading joining the tab order. Never delete focus outlines — restyle :focus-visible instead.