Skip to content
Accessibility & inclusive designLesson 4 of 7Intermediate16 min

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.

Kept on this device only.

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

Live demo
Tweak it3
Open the dialog and press Tab. A well-built dialog traps focus and hands it back on close.

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 Tab and Shift+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 keydown handler on the dialog calls your close function when Escape is 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 the inert attribute (or aria-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.

tsx
Dialog.tsx — focus in on open, restore on close, Escape to close
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-labelledby pointing at its title (or aria-label), and don't forget that aria-modal="true" describes the dialog but does not, by itself, make the background inert — use the inert attribute or aria-hidden for that.

Further reading