DesignDev.io logoDesignDev.io logo

  1. Home
  2. /React Foundations
  3. /Building a Fully Accessible Modal from Scratch in React
← Back to home

Building a Fully Accessible Modal from Scratch in React

Alex Chen

Alex Chen

Cover Image for Building a Fully Accessible Modal from Scratch in React
Alex Chen

Alex Chen

January 17, 2026·4 min read
Updated April 30, 2026
Series:React Foundations
Level:Intermediate
Tags:reactweb
Assumes:Comfortable with React hooks, refs, and useEffect. Basic ARIA familiarity helpful but not required

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 dialog
  • aria-modal="true" — tells screen readers to ignore content behind the modal
  • aria-labelledby — points to the modal's title so screen readers announce it when the modal opens
  • aria-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:

  1. On open: move focus into the modal — to the first focusable element, or to the modal container itself if there are no focusable elements
  2. 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.

If you found this article interesting checkout our series on building your own react library from scratch.

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


More Stuff

Publishing Your First React Component to npm — the Complete Setup
April 1, 2026·3 min read

Publishing Your First React Component to npm — the Complete Setup

Most tutorials stop at "it works on my machine." This one doesn't. Publishing to npm is where your library becomes real — installable, versioned, and usable by anyone. Here's the complete setup, including the fields most developers get wrong in package.json and how to inspect what you're actually shipping before it goes live.

Muhammad Athar

Muhammad Athar

How to structure a UI library that scales — folders, exports, and naming conventions
April 1, 2026·3 min read

How to structure a UI library that scales — folders, exports, and naming conventions

The folder structure you pick on day one is the one you'll live with across 40 components. Get it wrong and you'll be fighting your own codebase by article 10. Get it right and adding a new component takes five minutes and follows a pattern every contributor can learn in one sitting.

Muhammad Athar

Muhammad Athar

DesignDev.io logoDesignDev.io logo

Explore

  • Home
  • Search
  • Authors
  • Series
  • About Us

Account

  • Sign in

Legal

  • Privacy Policy
  • Terms of Service

© 2026 DesignDev.io · All rights reserved

Built with Next.js · Tailwind · Sanity · Vercel