Building a Fully Accessible Modal from Scratch in React

Most modal implementations look right but fail accessibility checks. Focus trapping, scroll locking, ARIA attributes, and keyboard handling — each one is a trap for the unwary. Here's a complete implementation that gets all four right, including the iOS Safari scroll lock bug nobody warns you about.
Modals are one of those UI patterns that look simple and aren't. The visual part — a centered box with a backdrop — takes ten minutes. Getting the rest right takes considerably longer.
The rest: trapping focus inside the modal so keyboard users can't accidentally navigate the page behind it. Locking the scroll so the background doesn't drift when the modal is open. Announcing the modal to screen readers correctly. Closing on Escape. Returning focus to the trigger when it closes.
Skip any of these and you have a modal that looks fine in Chrome on a MacBook and fails for anyone using a keyboard, a screen reader, or an iPhone.
This article builds the full implementation — nothing skipped.
What we're building
A Modal component with this API:
Simple to use. Everything complex lives inside the component.
ARIA requirements
Before the code, the requirements. The ARIA authoring practices define the modal dialog pattern. Four attributes are essential:
role="dialog"— tells assistive technology this is a dialogaria-modal="true"— tells screen readers to ignore content behind the modalaria-labelledby— points to the modal's title so screen readers announce it when the modal opensaria-describedby— optionally points to a description
When this modal opens, a screen reader will announce something like: "Confirm deletion, dialog." The user immediately knows they're in a modal and what it's about.
Focus management
Two focus behaviours are required:
- On open: move focus into the modal — to the first focusable element, or to the modal container itself if there are no focusable elements
- On close: return focus to the element that triggered the modal
The previousFocusRef captures document.activeElement at the moment the modal opens — typically the button that triggered it. When the modal closes, focus returns there. Without this, keyboard users lose their place in the page.
Focus trapping
Focus trapping keeps Tab and Shift+Tab cycling within the modal. Without it, a keyboard user can Tab past the last focusable element in the modal and start navigating the page behind it — while the modal is still open.
This handles three cases: Tab at the last element wraps to the first, Shift+Tab at the first element wraps to the last, and Escape closes the modal.
Wire it up in a useEffect:
Scroll locking — including the iOS Safari bug
When a modal is open, the page behind it should not scroll. The naive implementation:
This works on desktop. On iOS Safari, it doesn't. iOS Safari ignores overflow: hidden on document.body — the page still scrolls behind the modal.
The iOS fix requires capturing the current scroll position and using position: fixed:
When the modal opens, we record scrollY, fix the body in place at that position, and set overflow: hidden. When it closes, we restore the styles and scroll back to where the user was. The page jumps back to the correct position without a flash.
Backdrop click to close
Clicking outside the modal content should close it. The implementation needs to distinguish between clicking the backdrop and clicking inside the modal:
event.target is the element that was clicked. event.currentTarget is the element the handler is attached to — the backdrop div. If they're the same element, the click was on the backdrop, not on a child inside the modal.
The complete implementation
Demo: a confirmation modal
Let’s use the modal that we implemented above in an example UI:
Test it with a keyboard: Tab around the page, open the modal with Enter, Tab through the modal's buttons, confirm that Tab wraps, press Escape to close, confirm focus returns to the button that opened it.
That's the full test. If all of that works, the modal is accessible.
Why not just use <dialog>?
The native <dialog> element handles focus management, Escape key, and backdrop automatically. Dev Okonkwo covers it in detail in The <dialog> element — native modals without a library.
The reason to build from scratch: full styling control, react-error-boundary integration, custom animation, and understanding what the browser gives you for free so you know what you'd be giving up by using a library.
In a real project, react-aria-components or Radix UI's Dialog are the production-ready options. They implement everything in this article — and more — and are tested across dozens of browser and screen reader combinations. Building from scratch is the education; using a battle-tested library is often the right production decision.
Up next: Why React.memo doesn't always help — and when it does — the memoization article that connects reconciliation, component identity, and render optimisation.
Related: The inert attribute — disabling entire DOM subtrees for accessibility by Dev Okonkwo — the HTML attribute that replaces manual focus trapping in many cases.
Alex Chen is a senior frontend engineer who writes about React patterns, JavaScript internals, and the decisions that separate maintainable codebases from ones that fight back. Opinionated by design.
Photo by Krišjānis Kazaks on Unsplash

