Accessible Modal Dialog

Modals are great at forcing attention. They are also great at locking keyboard users in a trap or quietly confusing screen readers if you get the details wrong.

This guide walks through why accessibility matters for dialogs, which WCAG criteria you are poking at, and how to build a modal that behaves calmly for everyone, not just people with a mouse.

Live demo: Accessible modal dialog

Try opening the dialog and walking it with Tab and Shift+Tab. Press Escape to close. Screen readers get proper dialog semantics.


Why modal accessibility actually hurts in real life

If you only test your product with a mouse on a big screen, most modals look fine:

  • There is a slightly dramatic overlay
  • Something pops up in the middle
  • There is a "Cancel" and a "Save" button

You click around a bit and move on.

The problems show up in the boring places:

  • Someone navigates by keyboard on a laptop in a train
  • Someone uses a screen reader and has no visual sense of overlays
  • Someone gets a "confirm destructive action" dialog during a workflow and cannot get back out

I have seen production dialogs where:

  • Focus stayed behind the overlay, so pressing Tab moved through links you could not see
  • Escape did absolutely nothing, so the only way out was to reload the page
  • Screen readers never announced that anything opened, so the UI changed under the user with no explanation

None of that shows up in a happy path demo. It shows up when you try to cancel an action with just a keyboard at the worst possible time.

From a WCAG point of view you are mainly touching:

  • 2.1.2 No Keyboard Trap – the dialog must not be a dead end
  • 2.4.3 Focus Order – focus moves in a logical order inside the dialog
  • 2.4.7 Focus Visible – it is always clear where you are
  • 4.1.2 Name, Role, Value – assistive tech can tell what this thing is, what it is called, and how to close it

You can either learn those numbers by heart or treat them as one principle:

"If I steal focus, I have to give it back and explain what is happening."


The behaviour you actually want

Let's phrase the dialog in plain behaviour instead of ARIA jargon.

When the user opens the dialog:

  1. A clear trigger button opens it.
  2. Focus moves into the dialog, usually to the close button or the first field.
  3. Screen readers hear that a dialog opened, what it is called, and what it contains.
  4. The rest of the page is visually and practically "out of play" while the dialog is open.

While the dialog is open:

  1. Tab and Shift+Tab stay inside the dialog.
  2. Escape closes it.
  3. Clicking the dimmed background also closes it, but only if the user actually clicks the background, not by accident inside the dialog.

When the dialog closes:

  1. Focus returns to the element that opened it.
  2. The user can carry on without having to "start over" from the top of the page.

If you get those nine points right, you are ahead of most production dialogs on the web.


WCAG in plain language for this dialog

If you like the official names, here is how the previous list maps to WCAG:

  • 2.1.1 Keyboard / 2.1.2 No Keyboard Trap
    Everything you can do with a mouse, you can do with a keyboard. You can also leave again.

  • 2.4.3 Focus Order
    When you press Tab, focus moves through the dialog in a sensible order and does not wander into the page behind it.

  • 2.4.7 Focus Visible
    The focus ring is obvious. You can see which field or button is active.

  • 3.2.2 On Input
    Opening and closing the dialog does not suddenly trigger unrelated side effects.

  • 4.1.2 Name, Role, Value
    The dialog has a role, a name and a description. Assistive tech can see what it is and how to dismiss it.

You do not have to quote these in code reviews. But it helps to know that there is a checklist hiding under the hood and that you are ticking off several items just by implementing a boring dialog properly.


Step 1: Give the dialog a real identity

Your dialog is not "just a div". Assistive tech needs a proper role and clear labels.

<div
  role="dialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-description"
>
  <h2 id="dialog-title">Confirm deletion</h2>
  <p id="dialog-description">
    Are you sure you want to delete this item? This cannot be undone.
  </p>
  <button onClick={handleClose}>Cancel</button>
  <button onClick={handleConfirm}>Delete</button>
</div>

Why this matters:

  • role="dialog" tells screen readers that a dialog appeared, not just a random container.
  • aria-modal="true" tells assistive tech that the rest of the page is temporarily out of reach.
  • aria-labelledby and aria-describedby provide the spoken title and description so the user knows what the dialog is about.

Without these, users who rely on screen readers get a silent overlay that steals focus and never introduces itself.


Step 2: Move focus into the dialog

When the dialog opens, keyboard users should not have to go hunting for it.

In React, that looks like:

const dialogRef = useRef<HTMLDivElement | null>(null);

useEffect(() => {
  if (isOpen && dialogRef.current) {
    const focusable = getFocusableElements(dialogRef.current);
    if (focusable.length > 0) {
      focusable[0].focus();
    }
  }
}, [isOpen]);

If you skip this, focus often stays on the trigger behind the overlay. The user presses Tab, the visual highlight is somewhere they cannot see, and the UI feels haunted.


Step 3: Trap focus without turning it into a prison

Focus trapping is simple in principle:

  • While the dialog is open, Tab and Shift+Tab should stay inside it.
  • Tabbing past the last element wraps back to the first, and the other way around.

In the live demo, the key handler does exactly that:

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
  if (event.key === "Tab" && dialogRef.current) {
    const focusable = getFocusableElements(dialogRef.current);
    if (focusable.length === 0) return;

    const currentIndex = focusable.indexOf(
      document.activeElement as HTMLElement,
    );

    let nextIndex = currentIndex;

    if (event.shiftKey) {
      nextIndex = currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1;
    } else {
      nextIndex =
        currentIndex === focusable.length - 1 ? 0 : currentIndex + 1;
    }

    event.preventDefault();
    focusable[nextIndex].focus();
  }
};

You are not trying to be clever. You are just making sure the user does not fall through a hole into the page behind the dialog.


Step 4: Escape still means "get me out"

Escape is the universal "no thanks" key. For dialogs it should always close and give focus back to the trigger.

const triggerRef = useRef<HTMLButtonElement | null>(null);

useEffect(() => {
  if (!isOpen && triggerRef.current) {
    triggerRef.current.focus();
  }
}, [isOpen]);

const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
  if (event.key === "Escape") {
    event.preventDefault();
    onClose();
    return;
  }

  // Tab handling as above…
};

This is where "No Keyboard Trap" is won or lost. If Escape does nothing and focus cannot leave the dialog, you have built a trap, not a component.


Step 5: A small, reusable primitive instead of one-off modals

The whole point of a boring dialog is that you only have to get it right once.

In a larger codebase you can wrap the behaviour into a small primitive:

  • A useModal hook that returns isOpen, open, close and the trigger ref
  • A <Modal> component that handles role, ARIA, focus trap and overlay
  • Application code that only cares about what to render inside

That keeps all the tricky keyboard and ARIA work in one place and lets your product teams compose dialogs without re-implementing the rules every time.

You can treat AccessibleModalDemo on this page as a reference implementation and later extract the pattern into a reusable primitive when you have a couple of real use cases.


How to GS-TDD this dialog

This is Boring Reliability, so we do not stop at "it feels fine in the browser".

For a dialog, a small GS-TDD loop might look like:

  1. Write tests that describe the behaviour in plain language.
  2. Implement the Gold Standard dialog (not the minimal hack).
  3. Refactor into a hook or shared component once the tests are green.

An example test sketch with Testing Library:

it("moves focus into the dialog and back to the trigger", async () => {
  render(<YourDialogWrapper />);

  const openButton = screen.getByRole("button", { name: /open dialog/i });
  openButton.focus();
  await userEvent.click(openButton);

  // Focus should be inside the dialog
  const closeButton = await screen.findByRole("button", { name: /close dialog/i });
  expect(closeButton).toHaveFocus();

  // Escape closes and returns focus to trigger
  await userEvent.keyboard("{Escape}");
  expect(openButton).toHaveFocus();
});

You can add a Playwright test later that walks the Tab order in a real browser, but even a couple of unit tests like this will catch regressions the moment someone "simplifies" your focus logic.


Common mistakes to watch for

If you only remember one list from this article, make it this one:

  • Dialog opens visually but focus stays behind on the page
  • Dialog has no role or label, so screen readers never announce it
  • Escape does nothing, so the only escape route is the mouse
  • Tab order leaks into the page behind the dialog
  • Focus does not return to the trigger, leaving keyboard users at the top of the document
  • Overlay is purely visual and does not actually prevent interaction with the background content

These are all testable, boring behaviours. None of them require design workshops. They just need a bit of discipline.


A tiny checklist

Before you ship a modal, you can do a quick pass with this checklist:

  • [ ] Opens from a clear trigger
  • [ ] Focus moves inside on open
  • [ ] Role, label and description are set
  • [ ] Tab and Shift+Tab stay inside
  • [ ] Escape closes and returns focus
  • [ ] Background is visually dimmed and practically inactive
  • [ ] At least one test covers open, focus, Escape and close

If you can tick all of these without crossing your fingers, you have a boringly reliable dialog.