Skip to content
inklu
reactaccessibilitywcagcomponentsdeveloper

The Accessible Modal: Getting React Dialogs Right

Modals are where accessibility quietly breaks in most React apps. Here's what actually has to work — focus, escape, labeling, and the WCAG 2.2 rule nobody knew about.

By Moe· Co-founder, Design9 min read

Open a modal in almost any React app, then unplug your mouse. Try to close it with the keyboard. Try to tab through it. Watch where the focus goes when it opens, and where it lands when it closes.

Most of the time, one of those four things is broken. The dialog opens but focus stays back on the page behind it. Tab drifts out of the modal and starts cycling through the links underneath. Escape does nothing. Or you close it and focus vanishes to the top of the document, so a screen reader user is dumped back at the beginning with no idea what just happened.

A modal is a small component with a large number of accessibility obligations. It is also the single most common place we open pull requests for, because the pattern is easy to get 80% right and genuinely hard to get 100% right. Here is the full checklist, why each part matters, and the WCAG 2.2 success criterion that most teams have never heard of.

What a modal actually promises

A modal dialog makes an implicit promise to the user: everything else on the page is temporarily off-limits, and your attention belongs here until you finish or cancel. Sighted mouse users get that promise for free — there is a dimmed overlay, the dialog sits on top, and clicking outside usually closes it.

Keyboard and screen reader users get none of that for free. The dimmed overlay is a visual convention with no meaning to assistive technology. If you do not build the behavior explicitly, the "modal" is just a div that happens to float above other divs, and the user can tab straight past it into content they were told was unavailable.

So the real work of an accessible modal is making that promise true in code. Six things have to hold.

1. Focus moves into the dialog on open

When the modal opens, focus has to move to it — not stay on the button that triggered it. Where exactly it lands depends on the dialog. For a simple confirmation, move focus to the dialog container itself or to the first meaningful element. For a destructive action, move it to the safest button (usually "Cancel") so nobody confirms a delete by reflexively hitting Enter.

The mechanical version in React:

const dialogRef = useRef<HTMLDivElement>(null)

useEffect(() => {
  if (isOpen) {
    dialogRef.current?.focus()
  }
}, [isOpen])

The container needs tabIndex={-1} so it can receive programmatic focus without becoming a tab stop itself. This is the step people skip, and it is the difference between a screen reader announcing the dialog and a screen reader announcing nothing at all.

2. Focus is trapped while it is open

Tab and Shift+Tab must cycle within the dialog. When focus reaches the last focusable element and the user presses Tab, it wraps to the first. Shift+Tab from the first wraps to the last. Focus never reaches the page behind the overlay.

This is the part that maps to two WCAG success criteria at once. You are satisfying 2.4.3 Focus Order by keeping the tab sequence coherent, and you have to be careful not to violate 2.1.2 No Keyboard Trap — which sounds like a contradiction. It is not. 2.1.2 says the user must always be able to leave a component using the keyboard. A modal satisfies both because Escape (and the close button) are the documented way out. The trap is intentional and escapable, which is exactly what the spec allows.

Building a correct focus trap by hand means querying focusable descendants, handling Tab and Shift+Tab at the boundaries, and re-querying when the dialog's contents change. It is enough logic that we usually recommend a battle-tested primitive — the <dialog> element or a headless library — rather than a bespoke keydown handler that will drift out of sync the first time someone adds a field.

3. Escape closes it

Pressing Escape closes the dialog and does it without submitting anything. This is not optional and it is not a nice-to-have. It is how keyboard users honor the "cancel" affordance that mouse users get by clicking outside.

useEffect(() => {
  if (!isOpen) return
  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') onClose()
  }
  document.addEventListener('keydown', onKeyDown)
  return () => document.removeEventListener('keydown', onKeyDown)
}, [isOpen, onClose])

One caveat: if the dialog contains its own dismissible layer — an open combobox, a date picker — Escape should close that inner layer first, then the dialog on the second press. Nesting Escape correctly is fiddly, which is another argument for a primitive that already handles it.

4. Focus returns to where it came from on close

When the dialog closes, focus has to go back to the element that opened it. Not the top of the page. Not nowhere. Back to the trigger button, so the user's place in the document is preserved and the next Tab continues from where they were.

Capture the trigger before you open, restore it after you close:

const triggerRef = useRef<HTMLElement | null>(null)

function openDialog() {
  triggerRef.current = document.activeElement as HTMLElement
  setIsOpen(true)
}

function closeDialog() {
  setIsOpen(false)
  triggerRef.current?.focus()
}

Skip this and you get the most disorienting bug in the whole pattern: a screen reader user completes a task, the dialog disappears, and they are silently relocated to the start of the page with no announcement of what happened.

5. The dialog is named and announced

Assistive technology has to know three things: that this is a dialog, that it is modal, and what it is called. That is role="dialog" (or the native <dialog> element), aria-modal="true", and an accessible name via aria-labelledby pointing at the heading — or aria-label if there is no visible title.

<div
  ref={dialogRef}
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  tabIndex={-1}
>
  <h2 id="dialog-title">Delete this project?</h2>
  {/* ... */}
</div>

aria-modal="true" is what tells the screen reader to hide the rest of the page from its virtual cursor. Without it, a user can still arrow through the content behind the dialog even if you have trapped Tab focus, because browse mode does not follow the tab order. This is precisely the kind of gap that passes a quick manual click-through and fails the moment a real screen reader user arrives — the sort of thing source-code scanning catches that an overlay never will.

6. The dialog is not obscured when focus lands on it

This is the one almost nobody knows, because it is new. WCAG 2.2 added success criterion 2.4.11 Focus Not Obscured (Minimum), and it applies squarely to modals. When an element receives keyboard focus, it must not be entirely hidden behind other content — a sticky header, a cookie banner, a toast notification.

Modals interact with this in a way that bites teams during audits. If your dialog animates in from the bottom and a focusable element inside it is still partially under a sticky footer during the transition, or if focus moves to a field that scrolls under a fixed toolbar, you can fail 2.4.11 even though every other part of the pattern is perfect. The fix is usually a scroll-into-view on focus and enough padding that focused controls clear any fixed chrome. It is a small thing that a pre-2.2 checklist will not even mention, which is why so many "we already fixed our modals" teams still show a violation here.

The honest recommendation: don't hand-roll it

Read back through those six requirements. Focus management, a trap that is also escapable, Escape handling with nested layers, focus restoration, three ARIA attributes, and a brand-new 2.2 criterion about obscured focus. Every one of them is a place to introduce a bug, and several of them only surface with an actual screen reader, not a glance at the code.

The native <dialog> element now handles a good portion of this — focus trapping, the top layer, and Escape via requestClose() come for free, and browser support is finally broad enough to rely on. It does not solve everything: you still owe it the accessible name, you still have to decide where initial focus lands, and 2.4.11 is still yours to get right. But it is a far better starting point than a div with an overlay and a useState.

If you are already deep in a component library, a headless dialog primitive gets you the same guarantees with more styling control. What you should not do is write the keydown handler yourself on a deadline and assume it is correct. It will be correct for the cases you tested and wrong for the one a user hits.

Where this fits in a real workflow

None of this is exotic. It is well-documented, it has been stable for years, and yet modals remain one of the most reliable sources of WCAG failures in production React apps — because the pattern rewards looking-done over being-done, and because most of the failures are invisible until someone navigates by keyboard or screen reader.

That gap between "looks fine" and "actually works" is the whole reason inklu exists. We scan your web app and your codebase against WCAG 2.2 using axe-core 4.11 plus 50-plus proprietary rules, and when we find a dialog that traps focus without an escape hatch, or a modal missing aria-modal, or a focused control sitting under a sticky header, we do not just file a ticket. We open a GitHub pull request with the fix — real JSX and TSX edits, generated with GPT-4o, scoped to the violation, delivered as a diff you review and merge. Your source code is purged after every scan; nothing is retained.

A repo scan is 8 tokens; each auto-fix PR is 1. On the Starter plan at $29 a month that is enough to sweep a small app and open fixes for a meaningful chunk of what it finds. You can wire the scan into GitHub Actions so new modals get checked on every pull request, before the inaccessible one ships.

Modals are a good place to start because they are contained, high-traffic, and almost always broken in at least one of the six ways above. Fix the dialog pattern once, enforce it in CI, and you close off an entire category of complaints.

Want to see it run against your own dialogs? Book a demo at inklu.io, or email hello@inklu.io and we will point a scan at your repo.