Manage modal focus

Focus the dialog on open, close on Escape, and restore focus on close.

From frontend-patterns, “Accessibility Patterns: Focus Management”.

Where does focus go?

Tab to button 12, press Enter, then Tab and Escape.

Focus left behindBad
activeElement: — outside: 0
Focus in, then backGood
activeElement: — outside: 0
export function Modal({ isOpen, onClose, children }: ModalProps) {
  const modalRef = useRef<HTMLDivElement>(null)
  const previousFocusRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement as HTMLElement
      modalRef.current?.focus()
    } else {
      previousFocusRef.current?.focus()
    }
  }, [isOpen])

  return isOpen ? (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="rename-title"
      tabIndex={-1}
      onKeyDown={e => e.key === 'Escape' && onClose()}
    >
      {children}
    </div>
  ) : null
}

What a screen reader hears

Tab to Delete, press Enter, then Escape.

A styled divBad
INV-2041Acme Co · $1,200.00
      role="dialog", focusedGood
      INV-2041Acme Co · $1,200.00
          <div role="dialog" aria-modal="true" tabIndex={-1}
               aria-labelledby="del-title" aria-describedby="del-desc">
            <h3 id="del-title">Delete INV-2041?</h3>
            <p id="del-desc">This can’t be undone.</p>
            …
          </div>
          // focus() on open → "Delete INV-2041?, dialog, modal. This can’t be undone."

          Why it matters, pros and cons

          Why it matters

          Focus is the keyboard user's cursor. A modal that drops it loses their place.

          Pros

          • Opening announces the dialog’s name and role.
          • Escape closes, as every user expects.
          • Restored focus keeps their place.

          Cons

          • Not a trap: Tab still leaves without inert.
          • Native <dialog> showModal() does all this free.
          • A deleted opener can’t take focus back.