react-aria-modal vs react-focus-lock vs react-modal vs react-scrolllock
Managing Accessibility and Focus in React Modal Dialogs
react-aria-modalreact-focus-lockreact-modalreact-scrolllockSimilar Packages:

Managing Accessibility and Focus in React Modal Dialogs

react-aria-modal, react-focus-lock, react-modal, and react-scrolllock are all npm packages designed to help developers implement accessible modal dialogs in React applications. These tools address common accessibility challenges such as trapping focus within the modal, preventing background scrolling, and ensuring screen reader compatibility. While some packages provide full modal components, others offer composable utilities that can be integrated into custom modal implementations.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
react-aria-modal01,03947.8 kB322 years agoMIT
react-focus-lock01,387111 kB94 months agoMIT
react-modal07,414188 kB211a year agoMIT
react-scrolllock0467-376 years agoMIT

Managing Accessibility and Focus in React Modal Dialogs

Building an accessible modal dialog in React involves more than just showing a popup. You must:

  • Trap keyboard focus inside the modal
  • Prevent background scrolling
  • Set proper ARIA attributes (role="dialog", aria-modal="true")
  • Restore focus to the triggering element when closed

The four packages — react-aria-modal, react-focus-lock, react-modal, and react-scrolllock — each tackle these requirements differently. Let’s compare how they work in practice.

🧩 Architecture: Full Component vs Composable Utilities

react-aria-modal is a complete modal component that wraps your content and handles everything: focus trapping, ARIA roles, and scroll locking.

import AriaModal from 'react-aria-modal';

function MyModal({ isOpen, onClose }) {
  return (
    <AriaModal
      titleText="My Modal"
      mounted={isOpen}
      onExit={onClose}
      initialFocus="#modal-close-button"
    >
      <div id="modal-close-button" onClick={onClose}>Close</div>
      <p>Modal content here</p>
    </AriaModal>
  );
}

react-focus-lock is a composable utility that only handles focus trapping. You use it as a wrapper around your own modal markup.

import { FocusLock } from 'react-focus-lock';

function MyModal({ isOpen, onClose }) {
  if (!isOpen) return null;
  return (
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title">
      <FocusLock returnFocus>
        <h2 id="modal-title">My Modal</h2>
        <button onClick={onClose}>Close</button>
        <p>Modal content here</p>
      </FocusLock>
    </div>
  );
}

react-modal is a full modal component with extensive props for customization, including ARIA and focus behavior.

import ReactModal from 'react-modal';

// Must set app root once
ReactModal.setAppElement('#root');

function MyModal({ isOpen, onClose }) {
  return (
    <ReactModal
      isOpen={isOpen}
      onRequestClose={onClose}
      contentLabel="My Modal"
      shouldCloseOnEsc={true}
    >
      <button onClick={onClose}>Close</button>
      <p>Modal content here</p>
    </ReactModal>
  );
}

react-scrolllock is a single-purpose utility that only disables body scroll. It does nothing for focus or ARIA.

import ScrollLock from 'react-scrolllock';

function MyModal({ isOpen, onClose }) {
  if (!isOpen) return null;
  return (
    <>
      <ScrollLock /> {/* Prevents background scroll */}
      <div role="dialog" aria-modal="true">
        <button onClick={onClose}>Close</button>
        <p>Modal content here</p>
      </div>
    </>
  );
}

🔒 Focus Management: How Each Package Handles Keyboard Trapping

All packages except react-scrolllock manage focus, but with different approaches.

react-aria-modal automatically:

  • Moves focus to the modal on open
  • Traps Tab/Shift+Tab within modal children
  • Restores focus to the trigger element on close

It uses a hidden "sentinel" div technique to detect focus escape attempts.

react-focus-lock provides more granular control:

  • Supports multiple focus traps in nested modals
  • Allows whitelisting elements outside the lock (e.g., for tooltips)
  • Handles edge cases like dynamically added content
<FocusLock
  autoFocus={false} // disable auto-focus
  shards={[tooltipRef]} // allow focus on tooltip
>
  {/* modal content */}
</FocusLock>

react-modal requires you to:

  • Explicitly set appElement to hide background content from screen readers
  • Optionally specify shouldFocusAfterRender and shouldReturnFocusAfterClose

Its focus trapping works reliably but offers fewer escape hatches than react-focus-lock.

react-scrolllock provides no focus management. You must implement trapping yourself using useEffect and manual DOM focus handling — not recommended for production accessibility.

📱 Scroll Locking Behavior

react-aria-modal and react-modal both disable body scrolling by default using overflow: hidden on <body> and compensating for scrollbar width to prevent layout shift.

react-scrolllock does the same but only this — it’s essentially a one-trick pony:

// Internally, react-scrolllock does:
document.body.style.overflow = 'hidden';
document.body.style.paddingRight = `${scrollbarWidth}px`;

react-focus-lock does not handle scrolling. You’d need to combine it with react-scrolllock or a custom solution:

function AccessibleModal({ isOpen, onClose }) {
  return (
    <>
      {isOpen && <ScrollLock />} {/* from react-scrolllock */}
      <FocusLock returnFocus>
        {/* modal content */}
      </FocusLock>
    </>
  );
}

⚙️ Customization and Escape Hatches

react-aria-modal is the most opinionated:

  • Enforces a specific DOM structure
  • Limited styling hooks (relies on CSS classes)
  • Hard to customize focus behavior

react-focus-lock is the most flexible:

  • Works with any modal structure
  • Provides props like crossFrame, whiteList, and onActivation
  • Can be used outside modals (e.g., for drawers, menus)

react-modal strikes a balance:

  • Customizable via className, overlayClassName
  • Allows overriding ARIA attributes
  • Supports portal customization

react-scrolllock has no customization — it just locks scroll.

🛑 Deprecation and Maintenance Status

As of 2024:

  • react-scrolllock is deprecated. Its npm page states: "This package is no longer maintained. Consider using body-scroll-lock or react-remove-scroll instead." Do not use in new projects.
  • react-aria-modal, react-focus-lock, and react-modal are actively maintained with recent releases addressing accessibility gaps and React 18 compatibility.

🧪 Real-World Recommendation Scenarios

Scenario 1: Building a Design System

You’re creating a reusable modal component for your company’s UI library.

  • Best choice: react-focus-lock + custom scroll lock
  • Why? Maximum control over DOM structure, styling, and behavior while guaranteeing focus trapping.

Scenario 2: Quick Internal Tool

You need a working modal fast for an admin dashboard with basic accessibility.

  • Best choice: react-modal
  • Why? Batteries-included with good defaults and large community support.

Scenario 3: Strict WCAG Compliance Audit

Your app must pass rigorous accessibility testing.

  • Best choice: react-aria-modal
  • Why? Built by accessibility experts with ARIA patterns baked in from the start.

Scenario 4: Already Using a UI Framework

You’re using Material UI or Ant Design which provide their own modals.

  • Best choice: None of these — use the framework’s modal
  • But if you must patch focus issues: react-focus-lock as a wrapper

📊 Summary Table

PackageTypeFocus TrappingScroll LockARIA HandlingCustomizableMaintenance Status
react-aria-modalFull component✅ Automatic✅ Built-in❌ Low✅ Active
react-focus-lockUtility✅ Configurable❌ Manual✅ High✅ Active
react-modalFull component✅ Configurable✅ Configurable✅ Medium✅ Active
react-scrolllockUtility❌ None❌ None❌ None🛑 Deprecated

💡 Final Guidance

  • Never use react-scrolllock alone for modals — it solves only 1/4 of the problem.
  • For new projects, prefer react-focus-lock if you value composability, or react-modal if you want convention over configuration.
  • Avoid mixing react-focus-lock with react-modal or react-aria-modal — they’ll conflict on focus management.
  • Always test with keyboard navigation and screen readers (VoiceOver, NVDA) regardless of the library chosen.

Remember: accessibility isn’t just about libraries — it’s about semantic HTML, proper labeling, and user testing. These tools help, but they don’t replace thoughtful implementation.

How to Choose: react-aria-modal vs react-focus-lock vs react-modal vs react-scrolllock

  • react-aria-modal:

    Choose react-aria-modal if you need a fully accessible modal component out of the box with minimal configuration. It handles focus trapping, ARIA roles, and scroll locking automatically, making it ideal for teams prioritizing WCAG compliance without wanting to assemble lower-level primitives. However, its opinionated structure may limit layout flexibility compared to more composable alternatives.

  • react-focus-lock:

    Choose react-focus-lock when you already have a custom modal implementation but need robust, battle-tested focus trapping that works across edge cases (e.g., portals, dynamic content). It’s a focused utility that doesn’t impose styling or structure, making it perfect for design systems or component libraries that require fine-grained control over modal behavior while ensuring accessibility.

  • react-modal:

    Choose react-modal if you want a widely adopted, full-featured modal component with built-in accessibility support and extensive customization options. It provides good defaults for ARIA attributes and focus management, though recent versions require explicit configuration of the app root element. Best suited for applications where rapid development and community support outweigh the need for minimal bundle size.

  • react-scrolllock:

    Choose react-scrolllock only if your primary concern is preventing background scrolling during modal display and you’re already handling focus management separately. It’s a narrow-purpose utility that disables body scroll without affecting focus or ARIA attributes. Avoid using it alone for modals; instead, pair it with react-focus-lock or similar for complete accessibility.

README for react-aria-modal

react-aria-modal


SEEKING CO-MAINTAINERS! Continued development of this project is going to require the work of one or more dedicated co-maintainers (or forkers). If you're interested, please comment in this issue.


A fully accessible and flexible React modal built according WAI-ARIA Authoring Practices.

This module provides a minimally styled "container" component to wrap your fully-styled "presentational" component. It provides the following features, while giving you complete control of the content:

  • Focus is trapped within the modal: Tab and Shift+Tab will cycle through the modal's focusable nodes without returning to the main document beneath.
  • Escape will close the modal.
  • Scrolling is frozen on the main document beneath the modal.
  • When the modal closes, focus returns to the element that was focused just before the modal activated.
  • The dialog element has an ARIA role of dialog (or alertdialog).
  • The dialog element has an ARIA attribute designating its title, either aria-label or aria-labelledby.
  • By default, clicking on the modal's underlay (outside the dialog element) will close the modal (this can be disabled).
  • The modal is appended to the end of document.body instead of its taking up its source-order position within the React component tree.

"Flexible" mostly means that this module provides absolutely minimal inline styles — just enough to get the thing working — but does not provide "complete" modal styling that would get in your way. You get to (have to) style the dialog yourself. (Maybe make a fancy-looking modal module that others could use, which depends on this one behind the scenes?)

Check out the demo.

Project Goals

  • Full accessibility
  • Maximum flexibility
  • Absolutely minimal styling
  • Modular construction: this module is built on top of a few small JS modules that could be used by other React and non-React frontend components:

If you like this kind of module (accessible, flexible, unstyled) you should also check out these projects:

Installation

npm install react-aria-modal

dist/react-aria-modal.js is the Babel-compiled file that you'll use.

React Dependency

Version 2+ is compatible with React >0.14.

Version 1+ is compatible with React 0.13.

Usage

Just provide the right props (see below) and pass the content of the modal as this component's child.

Look in demo/js/ for examples and view the demo, but here's a simple example:

const React = require('react');
const ReactDOM = require('react-dom');
const AriaModal = require('../../');

class DemoOne extends React.Component {
  constructor(props) {
    super(props);

    this.state = {
      modalActive: false
    };

    this.activateModal = this.activateModal.bind(this);
    this.deactivateModal = this.deactivateModal.bind(this);
    this.getApplicationNode = this.getApplicationNode.bind(this);
  }

  activateModal = () => {
    this.setState({ modalActive: true });
  };

  deactivateModal = () => {
    this.setState({ modalActive: false });
  };

  getApplicationNode = () => {
    return document.getElementById('application');
  };

  render() {
    const modal = this.state.modalActive
      ? <AriaModal
          titleText="demo one"
          onExit={this.deactivateModal}
          initialFocus="#demo-one-deactivate"
          getApplicationNode={this.getApplicationNode}
          underlayStyle={{ paddingTop: '2em' }}
        >
          <div id="demo-one-modal" className="modal">
            <div className="modal-body">
              <p>
                Here is a modal
                {' '}
                <a href="https://www.npmjs.com/package/react-aria-modal#">with</a>
                {' '}
                <a href="https://www.npmjs.com/package/react-aria-modal#">some</a>
                {' '}
                <a href="https://www.npmjs.com/package/react-aria-modal#">focusable</a>
                {' '}
                parts.
              </p>
            </div>
            <footer className="modal-footer">
              <button id="demo-one-deactivate" onClick={this.deactivateModal}>
                deactivate modal
              </button>
            </footer>
          </div>
        </AriaModal>
      : false;

    return (
      <div>
        <button onClick={this.activateModal}>
          activate modal
        </button>
        {modal}
      </div>
    );
  }
}

ReactDOM.render(<DemoOne />, document.getElementById('demo-one'));

Details

The modal can be activated in a couple of ways:

  • mounting the component without an mounted prop
  • passing true as the mounted prop

Similarly, the modal can be deactivated in a couple of ways:

  • unmounting the component
  • passing false as the mounted prop

Pass your dialog element as the child. And that's it.

When the modal is mounted, you'll notice the following:

  • Focus is trapped: only elements within the modal will receive focus as you tab through. This is done by focus-trap, via focus-trap-react.
  • The modal has the ARIA attributes it needs: a role of dialog (or alertdialog) and an aria-label or aria-labelledby attribute.
  • The main document's scroll is frozen (except on touchscreens). This is done by no-scroll.
  • Your content is set atop a fixed-position underlay. You can control the appearance and behavior of this underlay in various ways (see below).
  • Your content is horizontally centered. You can also vertically center it, if you wish.
  • The modal is appended to document.body, not inserted directly into the HTML source order, as you might assume; but it should still update correctly. (This makes positioning easier (no weird nested z-index troubles).)

API

NameType (Default)
alertBoolean
applicationNodeDOM Node
dialogClassString
dialogIdString ('react-aria-modal-dialog')
dialogStyleObject
escapeExitsBoolean (true)
focusDialogBoolean
focusTrapOptionsObject
focusTrapPausedBoolean
getApplicationNode() => void
includeDefaultStylesBoolean (true)
initialFocusString
mountedBoolean
onEnter() => void
onExit(event) => void
scrollDisabledBoolean (true)
titleIdString
titleTextString
underlayClassString
underlayClickExitsBoolean (true)
underlayColorString ('rgba(0,0,0,0.5)')
underlayStyleObject
verticallyCenterBoolean

Reference API

Any data-* or aria-* props that you provide will be passed directly to the modal's container <div>.

alert

Type: Boolean

If true, the modal will receive a role of alertdialog, instead of its default dialog. The alertdialog role should only be used when an alert, error, or warning occurs (more info).

applicationNode

Type: DOM Node

Provide your main application node here (which the modal should render outside of), and when the modal is open this application node will receive the attribute aria-hidden="true". This can help screen readers understand what's going on.

This module can't guess your application node, so you have to provide this prop to get the full accessibility benefit.

dialogClass

Type: String

Apply a class to the dialog in order to custom-style it.

Be aware that, by default, this module does apply various inline styles to the dialog element in order position it. To disable all inline styles, see includeDefaultStyles.

dialogId

Type: String

Default: react-aria-modal-dialog

Choose your own id attribute for the dialog element.

dialogStyle

Type: Object

Customize properties of the style prop that is passed to the dialog.

escapeExits

Type: Boolean

Default: true

By default, the Escape key exits the modal. Pass false, and it won't.

focusDialog

Type: Boolean

By default, when the modal activates its first focusable child will receive focus. However, if focusDialog is true, the dialog itself will receive initial focus — and that focus will be hidden. (This is essentially what Bootstrap does with their modal.)

See the example below.

focusTrapOptions

Type: Object

Customize properties of the focusTrapOptions prop that is passed to the modal dialog's focus trap. For example, you can use this prop if you need better control of where focus is returned.

focusTrapPaused

Type: Boolean

If true, the modal dialog's focus trap will be paused.

You won't typically need to use this prop. It used to be that the typical reason for pausing a focus trap was to enable nested focus traps; but as of focus-trap v4, the pausing and unpausing of hierachical traps is handled automatically.

getApplicationNode

Type: () => void

Same as applicationNode, but a function that returns the node instead of the node itself. This can be useful or necessary in a variety of situations, one of which is server-side React rendering. The function will not be called until after the component mounts, so it is safe to use browser globals and refer to DOM nodes within it (e.g. document.getElementById(..)), without ruining your server-side rendering.

includeDefaultStyles

Type: Boolean

Default: true

By default, styles are applied inline to the dialog and underlay portions of the component. However, you can disable all inline styles by setting includeDefaultStyles to false. If set, you must specify all styles externally, including positioning. This is helpful if your project uses external CSS assets.

Note: underlayStyle and dialogStyle can still be set inline, but these will be the only styles applied.

initialFocus

Type: String

By default, when the modal activates its first focusable child will receive focus. If, instead, you want to identify a specific element that should receive initial focus, pass a selector string to this prop. (That selector is passed to document.querySelector() to find the DOM node.)

Demo example 3 and an additional example below illustrate a good method if you want no initial visible focus. (Add tabIndex='0' to the modal's content and give it outline: 0;.)

mounted

Type: Boolean

By default, the modal is active when mounted, deactivated when unmounted. However, you can also control its active/inactive state by changing its mounted property instead.

The following two examples are near-equivalents — the first mounts and unmounts, while the second changes the mounted prop:

var MyComponent = React.createClass({
  ..
  render: function() {
    ..
    var modal = (this.state.modalActive) ? (
      <AriaModal onExit={this.myExitHandler}>
        {modalContents}
      </AriaModal>
    ) : false;
    return <div>{modal}</div>;
  },
});

var MyComponentTakeTwo = React.createClass({
  ..
  render: function() {
    ..
    return (
      <div>
        <AriaModal
          mounted={this.state.modalActive}
          onExit={this.myExitHandler}
        >
          {modalContents}
        </AriaModal>
      </div>
    );
  },
});

onEnter

Type: () => void

This function is called in the modal's componentDidMount() lifecycle method. You can use it to do whatever diverse and sundry things you feel like doing after the modal activates.

Demo Five, for example, uses it to modify class names and enable some CSS transitions.

onExit

Type: (event) => void

This function handles the state change of exiting (or deactivating) the modal. It will be invoked when the user clicks outside the modal (if underlayClickExits={true}, as is the default) or hits Escape (if escapeExits={true}, as is the default), and it receives the event that triggered it as its only argument.

Maybe it's just a wrapper around setState(); or maybe you use some more involved Flux-inspired state management — whatever the case, this module leaves the state management up to you instead of making assumptions. That also makes it easier to create your own "close modal" buttons; because you have the function that closes the modal right there, written by you, at your disposal.

You may omit this prop if you don't want clicks outside the modal or Escape to close it, so don't want to provide a function.

scrollDisabled

Type: Boolean

Default: true

If true, the modal dialog will prevent any scrolling behind the modal window.

titleId

Type: String

The id of the element that should be used as the modal's accessible title. This value is passed to the modal's aria-labelledby attribute.

You must use either titleId or titleText, but not both.

titleText

Type: String

A string to use as the modal's accessible title. This value is passed to the modal's aria-label attribute.

You must use either titleId or titleText, but not both.

underlayClass

Type: String

Apply a class to the underlay in order to custom-style it.

This module does apply various inline styles, though, so be aware that overriding some styles might be difficult. If, for example, you want to change the underlay's color, you should probably use the underlayColor prop instead of a class. If you would rather control all CSS, see includeDefaultStyles.

underlayClickExits

Type: Boolean

Default: true

By default, a click on the underlay will exit the modal. Pass false, and clicking on the underlay will do nothing.

underlayColor

Type: String (color value) or false

Default: rgba(0,0,0,0.5)

If you want to change the underlay's color, you can do that with this prop.

If false, no background color will be applied with inline styles. Presumably you will apply then yourself via an underlayClass.

underlayStyle

Type: Object

Customize properties of the style prop that is passed to the underlay.

The best way to add some vertical displacement to the dialog is to add top & bottom padding to the underlay. This is illustrated in the demo examples.

verticallyCenter

Type: Boolean

If true, the modal's contents will be vertically (as well as horizontally) centered.

AriaModal.renderTo(HTMLElement | string)

react-aria-modal uses react-displace to insert the modal into a new element at the end of <body>, making it easier to deal with positioning and z-indexes.

The static renderTo function returns a new component that renders modals into a specific element, rather than a newly created element at the bottom of the page.

Strings are used as selectors, passed to querySelector.

See demo six for an example.

More examples

An alert dialog that itself receives initial focus (but has no visible outline) and does not exit when the underlay is clicked, and is vertically centered:

var AriaModal = require('react-aria-modal');

var MyModal = React.createClass({
  ..
  render: function() {
    return (
      <AriaModal
        onExit={this.myExitHandler}
        alert={true}
        focusDialog={true}
        titleId='modal-title'
        underlayClickExits={false}
        verticallyCenter={true}
      >
        <div
          style={{ outline: 0 }}
          className='my-modal-dialog'
        >
          <h2 id='modal-title'>Alert!</h2>
          ..
        </div>
      </AriaModal>
    )
  }
})

Contributing & Development

Please note that this project is released with a Contributor Code of Conduct. By participating in this project you agree to abide by its terms.

Lint with npm run lint.

Test the demos with npm start.

Build with npm build.