Focus management
When UI appears or moves, focus has to follow. Learn to move focus into a dialog, trap it while open, restore it on close, and always keep it visible.
In one line: when something appears, moves, or disappears, the keyboard focus has to go with it — move focus into a dialog when it opens, keep it there while it's open, hand it back when it closes, and never let the focus ring vanish.
What it is
Focus is the single place on the page that receives keyboard input — the element with :focus. Most of the time the browser manages it for you: Tab walks through interactive elements in order. Focus management is the work you do at the moments the browser can't guess your intent: when a dialog opens, a panel expands, content is removed, or a route changes. At those moments you move focus programmatically with element.focus().
Two things make programmatic focus correct rather than disorienting. First, you move it only when the user's place genuinely changed — opening a dialog is a new context, so focus belongs inside it. Second, focus must stay visible: a :focus-visible indicator with real contrast, never outline: none with nothing in its place. A focus you can't see is a focus the keyboard user has lost.
Why it matters
A modal dialog with no focus management is effectively broken for anyone not using a mouse. Open it and the focus is still sitting on the page behind it: the user Tabs, and the ring marches through the content under the dialog they can't see. There's no obvious way into the dialog and no reliable way out. Screen-reader users are left reading the background as if the dialog weren't there.
Lost focus is lost place. When you remove the element that had focus — close a menu, delete a row — focus silently falls back to the top of the document, and the keyboard user is teleported away from their work with no signal. Restoring focus to a sensible anchor (the control that opened the thing) keeps people oriented. Get this right and the dialog is operable, predictable, and quiet; get it wrong and it's a trap in the bad sense.
See it
Tweak it3
Open the dialog and press Tab a few times. In the good mode, focus lands in the dialog, cycles between its controls, and Escape returns you to the trigger. Switch the mode to broken and try again: focus never enters the dialog, Tab drifts into the background, and closing leaves you stranded.
How it works
A correct modal dialog does five things, in order:
- Move focus in on open. When the dialog mounts, send focus to the first focusable control (or the dialog container itself) with a ref and an effect.
- Trap focus while open. Handle
TabandShift+Tab: when focus would leave the last element, wrap it to the first, and vice-versa. Nothing behind the dialog should be reachable. - Escape closes. A
keydownhandler on the dialog calls your close function whenEscapeis pressed. - Restore focus on close. Remember which element opened the dialog and call
.focus()on it again, so the user returns exactly where they left. - Hide and silence the background. Mark the dialog
aria-modal="true"and put the rest of the page out of reach with theinertattribute (oraria-hidden) so assistive tech ignores it.
The browser can do almost all of this for you. The native <dialog> element opened with dialog.showModal() moves focus in, renders a top-layer backdrop, makes the background inert, and closes on Escape — for free. You still restore focus on close and choose where focus lands, but you write far less of the trap yourself.
Build it
A minimal correct dialog: focus moves in on open via a ref and effect, returns to the trigger on close, Escape closes, and the dialog carries aria-modal and aria-labelledby.
function Dialog({ open, onClose }: { open: boolean; onClose: () => void }) {
const firstRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
// Remember what had focus, then move focus into the dialog on open.
useEffect(() => {
if (!open) return;
triggerRef.current = document.activeElement as HTMLElement;
firstRef.current?.focus();
}, [open]);
function close() {
onClose();
triggerRef.current?.focus(); // restore focus to the opener
}
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
onKeyDown={(e) => e.key === "Escape" && close()}
>
<h2 id="dialog-title">Rename project</h2>
<label htmlFor="name">Project name</label>
<input id="name" ref={firstRef} />
<button onClick={close}>Cancel</button>
<button onClick={close}>Confirm</button>
</div>
);
}The native <dialog> element is the simpler path: open it with dialog.showModal() and the browser handles the top layer, the backdrop, background inertness, and Escape — you only restore focus on close.
Make it yours
Use the controls beside the demo above to change dialog quality, initial focus, and restore focus on close — each change updates the example live.
Experiment in the playground- Switch between good and broken and Tab through each: the good dialog keeps focus inside, the broken one lets it escape behind the overlay.
- In the good mode, press Escape from any control and watch focus snap back to the "Open dialog" button — that's the restore step doing its job.
- Try the same flow as a screen-reader user would: in the broken mode the background is never silenced, so everything behind the dialog is still announced.
Reproduce it with an LLM
Reproduce it with an LLM
You are a senior front-end engineer. Build an accessible modal dialog with correct focus management: on open, move focus into the dialog (the first focusable element or the dialog itself); trap Tab/Shift+Tab inside while open; close on Escape and on backdrop click; and on close, RESTORE focus to the element that opened it. Use role=dialog with aria-modal=true and an aria-labelledby pointing at the title, render it in a way that hides the background from assistive tech (inert or aria-hidden on the rest), and give every control a visible :focus-visible style. Prefer the native <dialog> element if suitable and note why. Return only the component code with imports.
Pitfalls & accessibility
- Make the dialog's accessible name explicit with
aria-labelledbypointing at its title (oraria-label), and don't forget thataria-modal="true"describes the dialog but does not, by itself, make the background inert — use theinertattribute oraria-hiddenfor that.