react-focus-lock
react-focus-lock keeps browser focus inside a React subtree while that lock is active. It moves focus in on activation, observes real focus changes instead of simulating Tab, cycles through focusable elements, supports portals through groups or shards, can restore the previous target, and exposes declarative components plus hooks for programmatic movement. It is the focus-management slice of a modal, drawer, menu, or focused task. It does not add a dialog role, accessible label, Escape handling, backdrop, scroll lock, screen-reader isolation, animation, or portal container.
react-focus-lock is a mature choice for the narrow job of containing and restoring focus, especially across portals. Install a complete dialog primitive instead when you also need semantics, outside-content isolation, scrolling behavior, and dismissal.
Use it if
- You are building a custom modal, drawer, popover, or editor surface and only the focus-containment piece is missing
- Your focusable content crosses React portals and you need explicit group or shard support
- You need React 16.8 through 19 compatibility from one focus utility
- You need programmatic next, previous, first, last, or autofocus control within a focus scope
- You need a complete accessible dialog: the README explicitly pairs focus locking with scroll locking and hiding outside content from screen readers
- Your component library already manages focus: the project warns that two active focus managers can fight and break the user experience
- You expect focus to return automatically with default props: returnFocus defaults to false for compatibility and must be enabled
- You cannot accept wrapper and guard elements in the DOM: FocusLock renders a div by default and inserts hidden focus guards unless configured otherwise
- You are not using React: the README points to the lower-level focus-lock package for vanilla DOM and to a separate Vue integration
Setup reality
Install react-focus-lock and make sure the application supplies its React peer. Version 2.13.7 accepts React 16.8 through 19; @types/react is an optional peer and declarations ship with the package. There is no CSS, provider, native build, or configuration file. Wrapping content activates the lock and moves focus to the first eligible element by default. For a modal, add role=dialog, aria-modal, an accessible label, close behavior, a backdrop, scroll locking, and screen-reader isolation yourself. Set returnFocus because it is off by default, and keep the opener mounted long enough to receive focus. Restoration after unmount or deactivation is deferred with a zero-timeout, so assertions and manual focus changes must account for that timing. Initial focus can use data-autofocus or AutoFocusInside. Portaled controls need a shared group or refs in shards if they should participate in tab order; shards have no surrounding guards, so edge layouts may need InFocusGuard. The default wrapper is a div, but as and lockProps can change it and add attributes. Positive tabIndex values require hasPositiveIndices, while persistentFocus is intentionally aggressive and can prevent text selection. The type definitions mark focusOptions and allowTextSelection deprecated. Avoid multiple installed copies or nested traps from different libraries, because competing managers can repeatedly pull focus away from each other. Test keyboard traversal in real browsers, including Safari, where operating-system settings affect which elements Tab reaches; jsdom alone does not reproduce the browser focus model.
Patterns
Contain focus in a custom modallock-modal-focus
import FocusLock from 'react-focus-lock';
function Dialog({ onClose }) {
return (
<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
<FocusLock returnFocus>
<h2 id="dialog-title">Edit profile</h2>
<input aria-label="Display name" />
<button onClick={onClose}>Close</button>
</FocusLock>
</div>
);
}FocusLock handles focus only; add Escape handling, outside-content isolation, scroll locking, and any backdrop separately.
Choose the initial focus targetchoose-initial-focus
<FocusLock returnFocus>
<input aria-label="Search" data-autofocus />
<button>Apply</button>
<button>Cancel</button>
</FocusLock>If several elements have data-autofocus, the first eligible target wins when the lock activates.
Mark an autofocus region declarativelyautofocus-component
import FocusLock, { AutoFocusInside } from 'react-focus-lock';
<FocusLock>
<button>Before</button>
<AutoFocusInside>
<input aria-label="Project name" />
</AutoFocusInside>
</FocusLock>AutoFocusInside only affects lock activation and does nothing when rendered outside a FocusLock.
Disable a lock without unmounting its contenttoggle-focus-lock
<FocusLock
disabled={!open}
returnFocus
onActivation={() => setActive(true)}
onDeactivation={() => setActive(false)}
>
<Panel hidden={!open} />
</FocusLock>Focus restoration after disabling is asynchronous; wait for the next task before asserting document.activeElement.
Include portaled controls with a shardinclude-portal-shard
import { createPortal } from 'react-dom';
import { useRef } from 'react';
import FocusLock from 'react-focus-lock';
function Editor() {
const toolbarRef = useRef(null);
return (
<>
<FocusLock shards={[toolbarRef]}>
<textarea aria-label="Document" />
</FocusLock>
{createPortal(
<div ref={toolbarRef}><button>Bold</button></div>,
document.body,
)}
</>
);
}A shard participates in the lock but does not receive automatic focus guards around its own DOM position.
Join main and portaled lock regions by groupgroup-scattered-locks
const group = 'command-palette';
<FocusLock group={group}>
<input aria-label="Command" />
</FocusLock>
{createPortal(
<FocusLock group={group} disabled>
<button>Search files</button>
</FocusLock>,
document.body,
)}Only one lock in the group should be active; the disabled lock marks its portaled subtree as part of the scattered focus area.
Exclude a subtree from focus enforcementallow-unmanaged-focus
import FocusLock, { FreeFocusInside } from 'react-focus-lock';
<FocusLock>
<button>Locked action</button>
<FreeFocusInside>
<div id="third-party-modal-root" />
</FreeFocusInside>
</FocusLock>Use this when another focus manager owns the excluded subtree and would otherwise fight with react-focus-lock.
Put dialog attributes on the lock wrappercustomize-lock-wrapper
<FocusLock
as="section"
returnFocus
className="settings-dialog"
lockProps={{
role: 'dialog',
'aria-modal': true,
'aria-labelledby': 'settings-title',
}}
>
<h2 id="settings-title">Settings</h2>
<button>Save</button>
</FocusLock>FocusLock renders a div by default; as changes the element and lockProps forwards attributes other than className.
Move focus without trapping itmove-focus-on-mount
import { MoveFocusInside } from 'react-focus-lock';
<MoveFocusInside>
<input aria-label="Rename file" defaultValue="notes.txt" />
</MoveFocusInside>MoveFocusInside forces focus into its child area on mount but does not keep later focus from leaving.
Move focus with arrow keysnavigate-focus-scope
import { useFocusScope } from 'react-focus-lock';
function ArrowNavigation() {
const { focusNext, focusPrev } = useFocusScope();
return (
<div onKeyDown={(event) => {
if (event.key === 'ArrowDown') { event.preventDefault(); void focusNext(); }
if (event.key === 'ArrowUp') { event.preventDefault(); void focusPrev(); }
}}>
<button>One</button><button>Two</button><button>Three</button>
</div>
);
}useFocusScope must run below a FocusLock, including a disabled one; its movement methods return promises.
Control a focus region without trappingcontrol-focus-without-lock
import { useRef } from 'react';
import { useFocusController } from 'react-focus-lock';
function Toolbar() {
const ref = useRef(null);
const focus = useFocusController(ref);
return (
<div ref={ref}>
<button onClick={() => void focus.focusFirst()}>First item</button>
<button>Second item</button>
</div>
);
}useFocusController works without FocusLock and can combine several elements or refs into one programmatic scope.
Add guards around a scattered regionguard-portal-edge
import { InFocusGuard } from 'react-focus-lock';
<InFocusGuard />
<div ref={portalRef}>
<button>Portaled action</button>
</div>
<InFocusGuard />Guards become tabbable only when needed and can stop a shard at the end of the document from tabbing into browser chrome.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| focus-trap-react | npm | Choose it for a React wrapper around focus-trap with its activation and fallback-focus option model |
| @radix-ui/react-focus-scope | npm | Choose it when your UI already uses Radix primitives and you want the matching low-level focus scope |
| @headlessui/react | npm | Choose it when you need complete accessible dialogs, menus, and popovers rather than focus containment alone |
| react-focus-on | npm | Choose it for the author's combined focus lock, scroll lock, and outside-content isolation behavior |