These libraries address the complex challenges of building modal dialogs and managing keyboard focus in React applications. react-modal provides a ready-to-use modal component with built-in styles, while react-focus-lock and react-focus-on offer lower-level primitives for trapping focus within custom UIs. react-aria (Adobe) delivers a comprehensive set of unstyled hooks for building fully accessible components from scratch, adhering to WAI-ARIA standards. Choosing between them depends on whether you need a quick drop-in solution, a focus utility for custom designs, or a complete accessibility foundation for a design system.
Building modal dialogs in React involves more than just showing a div on top of content. You must trap keyboard focus, manage screen reader announcements, prevent background scrolling, and ensure the dialog is dismissible. The four packages listed here solve these problems at different levels of abstraction. Let's examine how they handle the core requirements of a modal implementation.
react-modal provides a complete <Modal> component. You pass content as children and control visibility with a boolean prop. It renders a portal automatically.
// react-modal: Complete component
import Modal from 'react-modal';
function MyModal({ isOpen, onClose }) {
return (
<Modal isOpen={isOpen} onRequestClose={onClose}>
<h2>Dialog Title</h2>
<p>Content goes here.</p>
</Modal>
);
}
react-aria gives you hooks like useModal and useDialog. You build the markup yourself, which allows full styling control but requires more setup.
// react-aria: Headless hooks
import { useModal, useDialog, FocusScope } from '@react-aria';
function MyModal({ isOpen, onClose }) {
const ref = useRef(null);
const { modalProps } = useModal();
const { dialogProps, titleProps } = useDialog({}, ref);
if (!isOpen) return null;
return (
<FocusScope restoreFocus>
<div {...modalProps} ref={ref} {...dialogProps}>
<h2 {...titleProps}>Dialog Title</h2>
<p>Content goes here.</p>
<button onClick={onClose}>Close</button>
</div>
</FocusScope>
);
}
react-focus-lock is a wrapper component. You place it around your custom modal content to enforce focus trapping.
// react-focus-lock: Focus wrapper
import FocusLock from 'react-focus-lock';
function MyModal({ isOpen, onClose }) {
if (!isOpen) return null;
return (
<FocusLock>
<div className="modal">
<h2>Dialog Title</h2>
<button onClick={onClose}>Close</button>
</div>
</FocusLock>
);
}
react-focus-on works similarly to react-focus-lock but is designed to be the main container for your modal, handling more side effects automatically.
// react-focus-on: Enhanced wrapper
import { FocusOn } from 'react-focus-on';
function MyModal({ isOpen, onClose }) {
return (
<FocusOn enabled={isOpen}>
<div className="modal">
<h2>Dialog Title</h2>
<button onClick={onClose}>Close</button>
</div>
</FocusOn>
);
}
Focus trapping prevents users from tabbing outside the modal. This is critical for accessibility.
react-modal handles this internally. You do not need to configure it, but you cannot easily change the logic.
// react-modal: Internal handling
// No extra code needed for focus trap
<Modal isOpen={true}>
<button>First Focusable</button>
<button>Last Focusable</button>
</Modal>
react-aria uses FocusScope to manage focus restoration and trapping. You must wrap your content explicitly.
// react-aria: Explicit FocusScope
import { FocusScope } from '@react-aria';
<FocusScope restoreFocus contain>
<button>First Focusable</button>
<button>Last Focusable</button>
</FocusScope>
react-focus-lock uses the FocusLock component to constrain focus. It returns focus to the trigger element when unmounted.
// react-focus-lock: Constrain focus
import FocusLock from 'react-focus-lock';
<FocusLock returnFocus>
<button>First Focusable</button>
<button>Last Focusable</button>
</FocusLock>
react-focus-on enables focus locking via the enabled prop. It is robust against dynamic content changes.
// react-focus-on: Enabled prop
import { FocusOn } from 'react-focus-on';
<FocusOn enabled={true}>
<button>First Focusable</button>
<button>Last Focusable</button>
</FocusOn>
When a modal opens, screen readers should ignore the background content. This is often overlooked.
react-modal sets aria-hidden on the app root automatically if you configure appElement.
// react-modal: Configure app element
Modal.setAppElement('#root');
<Modal isOpen={true}>...</Modal>
// Background #root gets aria-hidden="true" automatically
react-aria requires you to manage aria-hidden manually or use useOverlay which helps coordinate this state.
// react-aria: Manual or useOverlay
// You typically manage state to hide siblings
<div aria-hidden={isModalOpen}>Background Content</div>
react-focus-lock does not handle aria-hidden by default. You must implement this logic yourself.
// react-focus-lock: Manual implementation
// No built-in API for aria-hidden on siblings
<div className={isOpen ? 'hidden' : ''}>Background</div>
react-focus-on automatically sets aria-hidden on elements outside the lock when enabled. This is a key advantage over react-focus-lock.
// react-focus-on: Automatic aria-hidden
<FocusOn enabled={true}>
{/* Outside content is automatically hidden from AT */}
</FocusOn>
Preventing the background page from scrolling while the modal is open is a standard UX requirement.
react-modal provides shouldCloseOnEsc and scroll prevention via CSS on the body, often requiring custom styles.
// react-modal: CSS based
// You often need to add a class to body manually or via onRequestOpen
<Modal onRequestOpen={() => document.body.classList.add('no-scroll')}>
...
</Modal>
react-aria does not lock scroll by default. You need to use usePreventScroll hook.
// react-aria: usePreventScroll hook
import { usePreventScroll } from '@react-aria';
function Modal() {
usePreventScroll(); // Locks body scroll
return <div>...</div>;
}
react-focus-lock does not lock scroll. It focuses only on focus management.
// react-focus-lock: No scroll lock
// Requires external solution like react-remove-scroll
<FocusLock>
<div>Content</div>
</FocusLock>
react-focus-on includes scroll locking out of the box. It combines react-focus-lock and react-remove-scroll internally.
// react-focus-on: Built-in scroll lock
<FocusOn enabled={true}>
<div>Content</div>
{/* Body scroll is locked automatically */}
</FocusOn>
How much control do you have over the look and feel?
react-modal comes with default styles that look dated. You must pass className and overlayClassName to override them completely.
// react-modal: Override styles
<Modal
className="my-custom-modal"
overlayClassName="my-custom-overlay"
>
Content
</Modal>
react-aria is unstyled. You apply all classes and styles. This is best for design systems.
// react-aria: Fully custom
<div {...props} className="flex items-center justify-center">
<div className="bg-white p-4 rounded">Content</div>
</div>
react-focus-lock is unstyled. It renders a div by default but accepts custom wrappers.
// react-focus-lock: Unstyled
<FocusLock className="my-focus-wrapper">
<div>Content</div>
</FocusLock>
react-focus-on is unstyled. It focuses on behavior, leaving visuals to you.
// react-focus-on: Unstyled
<FocusOn>
<div className="modal-styles">Content</div>
</FocusOn>
react-modal is a legacy library. It is stable but rarely updated with new React patterns. It is not recommended for new greenfield projects.
react-aria is actively maintained by Adobe. It is part of a larger ecosystem (react-spectrum) and follows modern React standards.
react-focus-lock and react-focus-on are maintained by the same author. They are widely used in the community and updated regularly to support React concurrent features.
| Feature | react-modal | react-aria | react-focus-lock | react-focus-on |
|---|---|---|---|---|
| Type | Full Component | Hooks Suite | Focus Primitive | Enhanced Primitive |
| Styling | Default + Override | Unstyled | Unstyled | Unstyled |
| Focus Trap | Built-in | FocusScope | FocusLock | FocusOn |
| Scroll Lock | Manual CSS | usePreventScroll | No (External) | Built-in |
| aria-hidden | Automatic (Root) | Manual / Hook | No | Built-in |
| Best For | Legacy / Quick | Design Systems | Custom Focus | Custom Modals |
react-modal is the old guard. It works, but it fights against modern styling systems like Tailwind or CSS-in-JS. Use it only if you need a modal tomorrow and don't care about perfect design integration.
react-aria is the professional choice for building systems. It requires more code upfront but guarantees accessibility compliance and flexibility. It is the foundation for custom design systems.
react-focus-lock is a utility belt item. Use it when you have a modal already but realize you forgot to trap focus. It fixes one specific problem well.
react-focus-on is the sweet spot for custom modals. It handles the annoying side effects (scroll, aria-hidden, focus) so you can focus on the UI. It is often the best choice for single-off custom modals in an app.
Choose react-focus-lock if you already have a modal or drawer component and simply need to ensure keyboard focus stays trapped inside it. It is a lightweight, surgical tool for focus management that does not impose styles or handle aria-hidden attributes, making it perfect for adding accessibility to existing custom UIs.
Choose react-aria if you are building a design system or need full control over markup and styling while ensuring strict WAI-ARIA compliance. It is ideal for teams that want to implement custom interactive patterns without reinventing accessibility logic, as it provides low-level hooks like useModal and useFocusTrap that integrate with your own components.
Choose react-focus-on if you want a slightly higher-level abstraction than react-focus-lock that also handles aria-hidden on outside elements and body scroll locking. It is suitable for custom modals where you need focus trapping plus basic accessibility and UX enhancements without the overhead of a full component library.
Choose react-modal only for legacy projects or internal tools where speed is more critical than design consistency. It is a complete solution with default styles, but it is less flexible for modern, headless architectures and may require significant effort to override default CSS for a custom look.
It is a trap! We got your focus and will not let him out!
a11y is asking for.
Trusted by Atlassian AtlasKit, ReachUI, SmoothUI, Storybook and we will do our best to earn your trust too!
π‘ focus locks is part of a bigger whole, consider scroll lock and text-to-speech lock you have to use to really "lock" the user. Try react-focus-on to archive everything above, assembled in the right order.
Just wrap something with focus lock, and focus will be moved inside on mount.
import FocusLock from 'react-focus-lock';
const JailForAFocus = ({onClose}) => (
<FocusLock>
You can not leave this form
<button onClick={onClose} />
</FocusLock>
);
Demo - https://codesandbox.io/s/5wmrwlvxv4.
FocusLock would work perfectly even with no props set.
FocusLock has few props to tune behavior, all props are optional:
disabled, to disable(enable) behavior without altering the tree.className, to set the className of the internal wrapper.returnFocus, to return focus into initial position on unmountBy default
returnFocusis disabled, so FocusLock will not restore original focus on deactivation. This was done mostly to avoid breaking changes. We strong recommend enabling it, to provide a better user experience.
This is expected behavior for Modals, but it is better to implement it by your self. See unmounting and focus management for details
persistentFocus=false, requires any element to be focused. This also disables text selections inside, and outside focus lock.autoFocus=true, enables or disables focusing into on Lock activation. If disabled Lock will blur an active focus.noFocusGuards=false disabled focus guards - virtual inputs which secure tab index.group=''' named focus group for focus scattering aka combined lock targetsshards=[] an array of ref pointing to the nodes, which focus lock should consider and a part of it. This is another way focus scattering.whiteList=fn you could whitelist locations FocusLock should carry about. Everything outside it will ignore. For example - any modals.as='div' if you need to change internal div element, to any other. Use ref forwarding to give FocusLock the node to work with.lockProps={} to pass any extra props (except className) to the internal wrapper.hasPositiveIndices=false to support a focus lock behavior when any elements tabIndex greater than 0.crossFrame=true enables aggressive focus capturing within iframesFocus lock exposes a few methods to control focus programmatically.
useFocusInside(nodeRef) - to move focus inside the given nodeuseFocusScope():{autofocus, focusNext, focusPrev} - provides API to manage focus within the current lockuseFocusState() - manages focus state of a given nodeuseFocusController(nodeRef) - low level version of useFocusScope working without FocusLock<AutoFocusInside/> - causes autofocus to look inside the component<MoveFocusInside/> - wrapper around useFocusInside, forcibly moves focus inside on mount<FreeFocusInside/> - hides internals from FocusLock allowing unmanaged focusFocus-lock behavior can be controlled via data-attributes. Declarative API above is working by setting them for you. See corresponding section in focus-lock for details
By default tabbing in OSX sees only controls, but not links or anything else tabbable. This is system settings, and Safari/Firefox obey.
Press Option+Tab in Safari to loop across all tabbables, or change the Safari settings. There is no way to fix Firefox, unless change system settings (Control+F7). See this issue for more information.
react-focus-lock exposed 3 entry points: for the classical usage, and a sidecar one.
import FocusLock from 'react-focus-lock would give you component you are looking for.Meanwhile - you dont need any focus related logic until it's needed. Thus - you may defer that logic till Lock activation and move all related code to a sidecar.
import FocusLockUI from 'react-focus-lock/UI - a DOM part of a lock.import Sidecar from 'react-focus-lock/sidecar - which is the real focus lock.import FocusLockUI from "react-focus-lock/UI";
import {sidecar} from "use-sidecar";
// prefetch sidecar. data would be loaded, but js would not be executed
const FocusLockSidecar = sidecar(
() => import(/* webpackPrefetch: true */ "react-focus-lock/sidecar")
);
<FocusLockUI
disabled={this.state.disabled}
sideCar={FocusLockSidecar}
>
{content}
</FocusLockUI>
That would split FocusLock into two pieces, reducing app size and improving the first load. The cost of focus-lock is just 1.5kb!
Saved 3.5kb?! π€·ββοΈ 3.5kb here and 3.5kb here, and your 20mb bundle is ready.
Use when you cannot use the native autoFocus prop - because you only want to autofocus once the Trap has been activated
data-autofocus on the element.data-autofocus-inside on the element to focus on something inside.AutoFocusInside component, as named export of this library. import FocusLock, { AutoFocusInside } from 'react-focus-lock';
<FocusLock>
<button>Click</button>
<AutoFocusInside>
<button>will be focused</button>
</AutoFocusInside>
</FocusLock>
// is the same as
<FocusLock>
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>
If there is more than one auto-focusable target - the first will be selected. If it is a part of radio group, and rest of radio group element are also autofocusable(just put them into AutoFocusInside) - checked one fill be selected.
AutoFocusInside will work only on Lock activation, and does nothing, then used outside of the lock.
You can use MoveFocusInside to move focus inside with or without lock.
import { MoveFocusInside } from 'react-focus-lock';
<MoveFocusInside>
<button>will be focused</button>
</MoveFocusInside>
Use focus scattering to handle portals
groups. Just create a few locks (only one could be active) with a same group nameconst PortaledElement = () => (
<FocusLock group="group42" disabled={true}>
// "discoverable" portaled content
</FocusLock>
);
<FocusLock group="group42">
// main content
</FocusLock>
shards. Just pass all the pieces to the "shards" prop.const PortaledElement = () => (
<div ref={ref}>
// "discoverable" portaled content
</div>
);
<FocusLock shards={[ref]}>
// main content
</FocusLock>
const PortaledElement = () => (
<div>
// NON-"discoverable" portaled content
</div>
);
<FocusLock shards={[ref]}>
// main content
<PortaledElement />
</FocusLock>
ComponentsYou may use as prop to change what Focus-Lock will render around children.
<FocusLock as="section">
<button>Click</button>
<button data-autofocus>will be focused</button>
</FocusLock>
<FocusLock as={AnotherComponent} lockProps={{anyAnotherComponentProp: 4}}>
<button>Click</button>
<span>Hello there!</span>
</FocusLock>
Let's take a look at the Rowing Focus as an example.
// Will set tabindex to -1 when is not focused
const FocusTrackingButton = ({ children }) => {
const { active, onFocus, ref } = useFocusState();
return (
<button tabIndex={active ? undefined : -1} onFocus={onFocus} ref={ref}>
{children}
</button>
);
};
const RowingFocusInternalTrap = () => {
const { autoFocus, focusNext, focusPrev } = useFocusScope();
// use useFocusController(divRef) if there is no FocusLock around
useEffect(() => {
autoFocus();
}, []);
const onKey = (event) => {
if (event.key === 'ArrowDown') {
focusNext({ onlyTabbable: false });
}
if (event.key === 'ArrowUp') {
focusPrev({ onlyTabbable: false });
}
};
return (
<div
onKeyDown={onKey}
// ref={divRef} for useFocusController
>
<FocusButton>Button1</FocusButton>
<FocusButton>Button2</FocusButton>
<FocusButton>Button3</FocusButton>
<FocusButton>Button4</FocusButton>
</div>
);
};
// FocusLock, even disabled one
const RowingFocusTrap = () => (
<FocusLock disabled>
<RowingFocusInternalTrap />
</FocusLock>
);
As you may know - FocusLock is adding Focus Guards before and after lock to remove some side effects, like page scrolling.
But shards will not have such guards, and it might be not so cool to use them - for example if no tabbable would be
defined after shard - you will tab to the browser chrome.
You may wrap shard with InFocusGuard or just drop InFocusGuard here and there - that would solve the problem.
import {InFocusGuard} from 'react-focus-lock';
// wrap with
<InFocusGuard>
<button />
</InFocusGuard>
// place before and after
<InFocusGuard />
<button />
<InFocusGuard />
InFocusGuards would be active(tabbable) only when tabble, it protecting, is focused.
If only your modal is the last tabble element on the body - you might remove the Tailing Guard, to allow user tab into address bar.
<InFocusGuard/>
<button />
// there is no "tailing" guard :)
returnFocus enabled, and it's going to be unmounted - focus will be returned after zero-timeout.returnFocus is set to false, and you are going to control focus change on your own - keep in mindReact will first call Parent.componentWillUnmount, and next Child.componentWillUnmount
This means - Trap will be still active by the time you may want move(return) focus on componentWillUnmount. Please deffer this action with a zero-timeout.
Similarly, if you are using the disabled prop to control FocusLock, you will need a zero-timeout to correctly restore focus.
<FocusLock
disabled={isFocusLockDisabled}
onDeactivation={() => {
// Without the zero-timeout, focus will likely remain on the button/control
// you used to set isFocusLockDisabled = true
window.setTimeout(() => myRef.current.focus(), 0);
}
>
In some cases the original node that was focused before the lock was activated is not the desired node to return focus to. Some times this node might not exists at all.
returnFocus, letting you decide where to return focus to.<FocusLock
returnFocus={(suggestedNode) => {
// somehow activeElement should not be changed
if(document.activeElement.hasAttributes('main-content')) {
// opt out from default behavior
return false;
}
if (someCondition(suggestedNode)) {
// proceed with the suggested node
return true;
}
// handle return focus manually
document.getElementById('the-button').focus();
// opt out from default behavior
return false;
}}
/>
read more at the issue #83 or mdn article.
To return focus, but without jumpy page scroll returning a focus you might specify a focus option
<FocusLock
returnFocus={{ preventScroll: false }} // working not in all browsers
>
Not supported by Edge and Safari.
Two different focus-lock-managers or even different version of a single one, being active simultaneously will FIGHT for the focus. This usually totally breaks user experience.
React-Focus-Lock will automatically surrender, letting another library to take the lead.
You may wrap some render branch with FreeFocusInside, and react-focus-lock will ignore
any focus inside marked node. So in case focus moves to uncontrolled location focus-lock will not trigger letting another library to act without interference in that another location.
import { FreeFocusInside } from 'react-focus-lock';
<FreeFocusInside>
<div id="portal-for-modals">
in this div i am going to portal my modals, dont fight with them please
</div>
</FreeFocusInside>
Another option for hybrid applications is to whiteList area where Focus-Lock should act, automatically allowing other managers in other areas.
The code below will scope Focus-Lock on inside the (react)root element, so anything jQuery can add to the body will be ignored.
<FocusLock whiteList={node => document.getElementById('root').contains(node)}>
...
</FocusLock>
React-Focus-Lock is expected to be a singleton. __Use webpack or yarn resolution for force only one version of react-focus-lock used.
webpack.conf
resolve: {
alias: {
'react-focus-lock': path.resolve(path.join(__dirname, './node_modules/react-focus-lock'))
...
From MDN Article about accessible dialogs:
This one is about managing the focus.
I've got a good article about focus management, dialogs and WAI-ARIA.
Uses focus-lock under the hood. It does also provide support for Vue.js and Vanilla DOM solutions
To create a "right" modal dialog you have to:
You may use react-focus-on to achieve everything above, assembled in the right order.
MIT