Accessibility

Accessible Modals in 5 Steps

Accessibility is mostly about following straightforward rules. It's easy until you have to build a form or a modal. Let’s tackle the latter…

Fotis Adamakis
Fotis Adamakis
Senior Software Engineer / Technical Writer
4 min read
June 27, 2025

Accessible Modals in 5 Steps

Accessible Modals in 5 Steps

Accessibility is mostly about following straightforward rules. It’s easy until you have to build a form or a modal. Let’s tackle the latter in a quick guide.

For an accessible modal, we need to:

  • Use correct ARIA roles
  • Trap focus while open
  • Close on Escape
  • Return focus to the trigger when closed
  • Label itself with aria-labelledby or aria-label

Most of this takes less than 30 lines of code. And we don’t really need a library.

Step 1: Semantic Markup

The first rule of accessibility: use semantic markup.

It gives screen readers metadata to create shortcuts for free.

And now, you might want to use the native dialog element, it was made for modals, after all. But in practice, it’s more trouble than it’s worth.

  • It’s hard and inconsistent to style
  • It doesn’t trap focus ⚠️
  • It breaks in React unless you call .showModal() explicitly 
    (no, {isOpen && <Modal />} doesn’t work)

What we’ll do instead is stick to a plain <div> with the right HTML attributes:

<div role="dialog" aria-modal="true">
  <h2>Your modal title</h2>
  <p>Some content here</p>
</div>

This works across all browsers, plays nicely with React, and behaves as expected for assistive tech.

Step 2: Trapping Focus

Once the modal opens, we shouldn’t be able to tab outside of it.

[Focus Trapping for Accessibility](https://medium.com/@im_rahul/focus-trapping-looping-b3ee658e5177)

That means:

  • Tab changes focus only within the modal
  • Shift+Tab cycles backwards inside the modal
  • Everything outside is temporarily off-limits

Here’s a minimal focus trap with no dependencies:

useEffect(() => {
  if (!isOpen) return;

  const focusableEls = modalRef.current.querySelectorAll(
    'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])',
  );
  const firstEl = focusableEls[0];
  const lastEl = focusableEls[focusableEls.length - 1];

  const trap = (e) => {
    if (e.key !== "Tab") return;

    if (e.shiftKey && document.activeElement === firstEl) {
      e.preventDefault();
      lastEl.focus();
    } else if (!e.shiftKey && document.activeElement === lastEl) {
      e.preventDefault();
      firstEl.focus();
    }
  };

  document.addEventListener("keydown", trap);
  return () => document.removeEventListener("keydown", trap);
}, [isOpen]);

Some low-level code and DOM manipulation here, but it should be plug and play for most cases.

Step 3: Close on Escape

Once focus is trapped inside the modal, we need a way out. By convention, the escape key is used for closing the Modal.

useEffect(() => {
  if (!isOpen) return;

  const handleKeyDown = (e) => {
    if (e.key === "Escape") onClose();
  };

  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, onClose]);

Remember to clean up every registered event listener to avoid bugs and memory leaks.

Step 4: Restore Focus

After a modal closes, focus shouldn’t just disappear. It should go back to the previous element, usually the button that opened the modal.

If we skip this, keyboard users get dropped at the top of the page or stuck outside the flow. That’s frustrating and disorienting.

const previouslyFocusedElement = useRef(null);

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

This captures the focused element before the modal opens, then restores it when the modal closes.

Remember to set tabIndex={-1} on the modal container so we can focus on it directly:

<div ref={modalRef} tabIndex={-1} role="dialog" aria-modal="true">
  {/\* content \*/}
</div>

Step 5: Label Your Modal

Every modal needs a name, so screen readers can announce it properly.

We’ve got two options:

Use aria-labelledby and point it to the ID of a heading inside the modal:

<div role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <h2 id="modal-title">Edit Profile</h2>
</div>

Or use aria-label If we don’t have a visible heading.

<div role="dialog" aria-modal="true" aria-label="Edit Profile">
  {/\* modal content \*/}
</div>

Prefer aria-labelledby when possible, it keeps things cleaner.

Using ARIA Labels Like a Pro

Bonus Tip: Don’t Open Modals Automatically

A modal should never open by default when the page loads, unless there’s a really good reason (like showing a critical error or system alert).

Why?

  • It traps keyboard users immediately without context
  • It hijacks focus before the user has interacted with anything
  • It’s disorienting for screen readers — they haven’t even had time to process the page yet

Instead, always tie modal opening to a user action (like clicking a button or pressing a shortcut). That way, the user is in control and so is their focus.

Test It Yourself

Want to see all of this in action?

Here’s a live demo you can poke at with your keyboard and screen reader.

Try it out:

  • Press Tab to move between elements
  • Hit Escape to close
  • Watch focus return to the trigger
  • Inspect the ARIA roles

It’s minimal but works without dependencies. 🚀

Fotis Adamakis

Fotis Adamakis

Senior Software Engineer / Technical Writer

Experienced software engineer writing about front end architecture, accessibility, system design, and developer productivity. Lessons from building and maintaining large-scale frontend applications, with a focus on practical patterns that make codebases easier to understand, scale, and evolve.

Barcelona, Spain