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…

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-labelledbyoraria-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:
Tabchanges focus only within the modalShift+Tabcycles 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.
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. 🚀


