react-aria-modal vs react-focus-lock vs react-modal vs react-scrolllock
React 中实现可访问性友好的模态框与焦点管理
react-aria-modalreact-focus-lockreact-modalreact-scrolllock类似的npm包:

React 中实现可访问性友好的模态框与焦点管理

react-aria-modalreact-focus-lockreact-modalreact-scrolllock 都是用于在 React 应用中处理模态框(Modal)交互的工具库,但各自关注点不同。react-aria-modal 是一个完整的、符合 ARIA 规范的模态框组件,内置了焦点捕获、滚动锁定和屏幕阅读器支持。react-focus-lock 专注于将键盘焦点限制在指定区域(如模态框内),防止用户意外聚焦到背景元素。react-modal 是一个功能全面的模态框组件,提供样式定制、焦点管理和辅助功能支持。react-scrolllock 则仅用于禁用页面滚动,常与其他模态方案配合使用。这些库共同解决模态交互中的可访问性、焦点控制和滚动行为问题。

npm下载趋势

3 年

GitHub Stars 排名

统计详情

npm包名称
下载量
Stars
大小
Issues
发布时间
License
react-aria-modal01,03947.8 kB322 年前MIT
react-focus-lock01,387111 kB94 个月前MIT
react-modal07,414188 kB2111 年前MIT
react-scrolllock0467-376 年前MIT

React 模态框生态深度对比:可访问性、焦点与滚动控制

在构建现代 Web 应用时,模态框(Modal)是常见但复杂的交互组件。除了视觉呈现,还需处理焦点管理滚动锁定屏幕阅读器兼容性等可访问性问题。react-aria-modalreact-focus-lockreact-modalreact-scrolllock 各司其职,本文从工程实践角度深入比较它们的设计哲学与技术实现。

🧩 核心职责与设计定位

react-aria-modal:全栈式 ARIA 模态框

react-aria-modal 是一个端到端的解决方案,严格遵循 WAI-ARIA Authoring Practices。它自动处理:

  • 焦点捕获(Focus trapping)
  • 背景滚动锁定
  • 屏幕阅读器公告(通过 aria-hidden 隐藏背景内容)
  • 自动聚焦到模态内容或指定元素
import AriaModal from 'react-aria-modal';

function MyModal({ isOpen, onClose }) {
  return (
    <AriaModal
      titleText="确认操作"
      mounted={isOpen}
      onExit={onClose}
      initialFocus="#confirm-button"
    >
      <div>
        <p>确定要删除吗?</p>
        <button id="confirm-button" onClick={onClose}>确认</button>
      </div>
    </AriaModal>
  );
}

react-focus-lock:专注焦点限制

react-focus-lock 只做一件事:确保 Tab 键导航不会离开被包裹的区域。它不处理模态渲染、滚动或 ARIA 属性,但可嵌入任何自定义模态组件中。

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

function CustomModal({ isOpen, onClose }) {
  if (!isOpen) return null;
  return (
    <div role="dialog" aria-modal="true">
      <FocusLock>
        <div>
          <p>自定义模态内容</p>
          <button onClick={onClose}>关闭</button>
        </div>
      </FocusLock>
    </div>
  );
}

react-modal:通用模态框组件

react-modal 提供完整的模态框实现,包括默认样式、打开/关闭动画、焦点管理和基础 ARIA 支持。开发者需显式配置 aria 属性和焦点行为。

import Modal from 'react-modal';

// 设置应用根元素以启用 aria-hidden
Modal.setAppElement('#root');

function AppModal({ isOpen, onRequestClose }) {
  return (
    <Modal
      isOpen={isOpen}
      onRequestClose={onRequestClose}
      contentLabel="示例模态框"
      shouldCloseOnOverlayClick={true}
    >
      <p>内容区域</p>
      <button onClick={onRequestClose}>关闭</button>
    </Modal>
  );
}

react-scrolllock:仅锁定滚动

react-scrolllock 的唯一功能是禁用页面滚动,通常在模态打开时使用。它不涉及 DOM 结构、焦点或 ARIA。

import ScrollLock from 'react-scrolllock';

function SimpleModal({ isOpen }) {
  return (
    <>
      {isOpen && <ScrollLock />}
      {isOpen && (
        <div className="modal-overlay">
          <div className="modal-content">内容</div>
        </div>
      )}
    </>
  );
}

🔍 关键能力对比

焦点管理实现方式

  • react-aria-modal:内置焦点捕获,自动将初始焦点设为模态内容或指定元素,并在关闭时恢复先前焦点。
  • react-focus-lock:通过 tabindex 和事件监听实现焦点循环,支持 returnFocus 选项恢复焦点。
  • react-modal:提供 shouldFocusAfterRendershouldReturnFocusAfterClose 控制焦点行为,但需手动处理复杂场景。
  • react-scrolllock不处理焦点,需配合其他方案使用。

滚动锁定机制

  • react-aria-modal:自动应用 overflow: hidden<body>,并补偿滚动条宽度以避免布局偏移。
  • react-focus-lock不处理滚动
  • react-modal:通过 bodyOpenClassName 添加 CSS 类来隐藏滚动条,但需自行处理宽度补偿。
  • react-scrolllock:直接设置 body.style.overflow = 'hidden',无宽度补偿逻辑。

ARIA 与可访问性支持

  • react-aria-modal:自动为背景内容添加 aria-hidden="true",确保屏幕阅读器仅读取模态内容。
  • react-focus-lock不处理 ARIA,需开发者手动添加 role="dialog"aria-modal
  • react-modal:要求调用 Modal.setAppElement() 以正确隐藏背景,但需验证是否符合最新规范。
  • react-scrolllock无 ARIA 支持

🛠️ 组合使用策略

在实际项目中,这些库常被组合使用以平衡灵活性与完整性:

场景 1:构建完全自定义的高可访问性模态框

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

function AccessibleCustomModal({ isOpen, onClose }) {
  if (!isOpen) return null;

  // 手动隐藏背景内容(简化示例)
  useEffect(() => {
    const body = document.body;
    const originalStyle = body.style.overflow;
    body.style.overflow = 'hidden';
    // 实际项目中需遍历所有非模态兄弟节点并设置 aria-hidden
    return () => {
      body.style.overflow = originalStyle;
    };
  }, []);

  return (
    <div role="dialog" aria-modal="true" aria-label="自定义模态">
      <FocusLock returnFocus>
        <ScrollLock />
        <div>
          <p>内容</p>
          <button onClick={onClose}>关闭</button>
        </div>
      </FocusLock>
    </div>
  );
}

场景 2:增强现有模态组件的焦点控制

若使用 react-modal 但发现焦点逃逸问题,可嵌入 react-focus-lock

<Modal isOpen={isOpen} onRequestClose={onClose}>
  <FocusLock>
    <div>强化焦点控制的内容</div>
  </FocusLock>
</Modal>

⚠️ 注意事项与弃用状态

  • react-scrolllock:根据 npm 页面信息,该包已标记为弃用(deprecated),作者建议使用 CSS 或其他方案替代。新项目应避免直接依赖它,改用 body { overflow: hidden } 配合 JavaScript 补偿滚动条宽度。
  • react-modal:虽然仍广泛使用,但其 ARIA 实现可能未完全跟上最新规范,需手动验证屏幕阅读器行为。
  • react-aria-modalreact-focus-lock:均由活跃维护者更新,符合当前可访问性最佳实践。

📊 总结:如何选择?

需求场景推荐方案
快速实现合规模态框react-aria-modal(全功能、低配置)
已有模态组件,仅需焦点控制react-focus-lock(轻量、专注)
需要高度定制外观和行为react-modal + 手动增强 ARIA
仅需临时禁用滚动避免 react-scrolllock,改用 CSS + JS 方案

💡 最佳实践建议

  1. 优先考虑可访问性:无论选择哪个库,务必用屏幕阅读器(如 VoiceOver 或 NVDA)测试模态交互。
  2. 避免重复功能:不要同时使用 react-aria-modalreact-focus-lock,前者已包含后者功能。
  3. 滚动锁定需谨慎:禁用滚动时,应补偿 bodypadding-right 以抵消滚动条消失导致的布局跳动。
  4. 新项目倾向组合方案:用 react-focus-lock 处理焦点 + 自定义 CSS 处理滚动 + 手动 ARIA 属性,获得最大控制力。

最终,没有“最好”的库,只有“最合适”当前项目约束和团队能力的方案。理解每个工具的核心职责,才能构建既健壮又包容的用户界面。

如何选择: react-aria-modal vs react-focus-lock vs react-modal vs react-scrolllock

  • react-aria-modal:

    选择 react-aria-modal 如果你需要一个开箱即用、严格遵循 WAI-ARIA 最佳实践的模态框组件,且希望避免手动集成焦点管理、滚动锁定和屏幕阅读器公告。它适合对无障碍要求高的项目,但定制样式和结构可能受限于其封装程度。

  • react-focus-lock:

    选择 react-focus-lock 如果你已经有一个自定义模态框组件,但需要可靠地将焦点限制在模态内容内部,并确保 Tab 键循环不会逃逸到背景。它轻量、专注,可与其他 UI 库或自定义模态方案无缝集成。

  • react-modal:

    选择 react-modal 如果你需要一个功能完整、高度可定制的模态框组件,同时兼顾基本的可访问性支持。它提供丰富的 API 来控制打开/关闭、动画、样式和焦点行为,适合大多数通用模态场景,但需自行确保完全符合最新 ARIA 规范。

  • react-scrolllock:

    选择 react-scrolllock 如果你只需要在模态打开时禁用页面滚动,而其他行为(如焦点管理)由其他库或自定义逻辑处理。它非常轻量,通常作为辅助工具与其他模态解决方案搭配使用,不建议单独用于构建完整模态体验。

react-aria-modal的README

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.