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

Building a Badge Component — Variants, Sizes, and Dot Indicators
April 3, 2026·3 min read

Building a Badge Component — Variants, Sizes, and Dot Indicators

Badge is the first purely presentational primitive in this series — no form integration, no event handling, no focus management. That simplicity is deceptive. A Badge that looks right in isolation often breaks inside a nav item, a table cell, or an avatar. The decisions that make it composable are worth getting right now.

Muhammad Athar

Muhammad Athar

Building a Label Component That Actually Connects to Its Field
April 3, 2026·3 min read

Building a Label Component That Actually Connects to Its Field

We've referenced a Label component in every form field article so far without building it. That's intentional — you need to understand what the Label connects to before you understand what it needs to do. Now that we have Input, Textarea, Checkbox, RadioGroup, Select, and Toggle, the Label's job is clear: it's not just styled text. It's the accessible name of a form field, and it carries required indicators, optional markers, and disabled state as part of that responsibility.

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