diff --git a/eslint.config.js b/eslint.config.js
index fd0c9ec..655d701 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -31,6 +31,8 @@ module.exports = [
require: 'readonly',
console: 'readonly',
KeyboardEvent: 'readonly',
+ HTMLElement: 'readonly',
+ Element: 'readonly',
},
},
plugins: {
diff --git a/src/FocusBracket.tsx b/src/FocusBracket.tsx
new file mode 100644
index 0000000..32dc4ea
--- /dev/null
+++ b/src/FocusBracket.tsx
@@ -0,0 +1,7 @@
+import type { FC } from 'react';
+
+// Invisible + focusable sentinels placed on each side of the dialog
+// Tabbing past either edge lands on a bracket (outside the trap) and gets bounced back in
+export const FocusBracket: FC = () => (
+
+);
diff --git a/src/ModalView.web.tsx b/src/ModalView.web.tsx
index d685919..808fcd1 100644
--- a/src/ModalView.web.tsx
+++ b/src/ModalView.web.tsx
@@ -4,6 +4,8 @@ import type { FC } from 'react';
import { createPortal } from 'react-dom';
import { StyleSheet, View, Pressable } from 'react-native';
+import { FocusBracket } from './FocusBracket';
+import { useFocusTrap } from './hooks/useFocusTrap';
import { useModalStack } from './hooks/useModalStack';
import type { ModalViewProps } from './types';
@@ -40,6 +42,8 @@ export const ModalView: FC = ({
const currentModalId = modalId ?? reactId;
const { isTopmost } = useModalStack(currentModalId);
+ const contentRef = useFocusTrap(isTopmost);
+
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
@@ -96,13 +100,18 @@ export const ModalView: FC = ({
)}
+
{children}
+
,
document.body,
);
diff --git a/src/focusHelpers.ts b/src/focusHelpers.ts
new file mode 100644
index 0000000..6514e24
--- /dev/null
+++ b/src/focusHelpers.ts
@@ -0,0 +1,113 @@
+export const maybeGetElement = (node: unknown): HTMLElement | null =>
+ node instanceof HTMLElement ? node : null;
+
+export const getModalRoot = (element: HTMLElement): HTMLElement => {
+ let root = element;
+
+ while (root.parentElement && root.parentElement !== document.body) {
+ root = root.parentElement;
+ }
+
+ return root;
+};
+
+export const inertBackground = (modalRoot: Element): (() => void) => {
+ const backgrounded = Array.from(document.body.children).filter(
+ node => node !== modalRoot && !node.hasAttribute('inert'),
+ );
+
+ backgrounded.forEach(node => node.setAttribute('inert', ''));
+
+ return () => {
+ backgrounded.forEach(node => node.removeAttribute('inert'));
+ };
+};
+
+export const onDocumentFocus = (handler: () => void): (() => void) => {
+ document.addEventListener('focus', handler, true);
+
+ return () => {
+ document.removeEventListener('focus', handler, true);
+ };
+};
+
+const attemptFocus = (element: HTMLElement): boolean => {
+ try {
+ element.focus();
+ } catch {
+ // .focus() can throw in rare cases (e.g. a detached node)
+ }
+
+ return document.activeElement === element;
+};
+
+export const focusFirstDescendant = (element: HTMLElement): boolean => {
+ const { children } = element;
+
+ for (let i = 0; i < children.length; i++) {
+ const child = children[i];
+
+ if (
+ child instanceof HTMLElement &&
+ (attemptFocus(child) || focusFirstDescendant(child))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+export const focusLastDescendant = (element: HTMLElement): boolean => {
+ const { children } = element;
+
+ for (let i = children.length - 1; i >= 0; i--) {
+ const child = children[i];
+
+ if (
+ child instanceof HTMLElement &&
+ (attemptFocus(child) || focusLastDescendant(child))
+ ) {
+ return true;
+ }
+ }
+
+ return false;
+};
+
+export const createFocusTrap = (
+ getModalContent: () => HTMLElement | null,
+): (() => void) => {
+ let trapInProgress = false;
+ let lastFocused: Element | null = null;
+
+ return () => {
+ const modalContent = getModalContent();
+
+ if (!modalContent || trapInProgress) {
+ return;
+ }
+
+ trapInProgress = true;
+
+ try {
+ const activeElement = document.activeElement;
+
+ if (activeElement && !modalContent.contains(activeElement)) {
+ let hasFocused = focusFirstDescendant(modalContent);
+
+ if (lastFocused === document.activeElement) {
+ hasFocused = focusLastDescendant(modalContent);
+ }
+
+ if (!hasFocused) {
+ modalContent.focus();
+ }
+ }
+ } finally {
+ trapInProgress = false;
+ }
+
+ lastFocused = document.activeElement;
+ };
+};
diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts
new file mode 100644
index 0000000..1de83ef
--- /dev/null
+++ b/src/hooks/useFocusTrap.ts
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from 'react';
+import type { View } from 'react-native';
+
+import {
+ createFocusTrap,
+ getModalRoot,
+ inertBackground,
+ maybeGetElement,
+ onDocumentFocus,
+} from '../focusHelpers';
+import { useRestoreFocus } from './useRestoreFocus';
+
+export function useFocusTrap(isTopmost: boolean) {
+ const contentRef = useRef(null);
+
+ useRestoreFocus();
+
+ useEffect(() => {
+ if (!isTopmost) {
+ return;
+ }
+
+ const modalContent = maybeGetElement(contentRef.current);
+
+ if (!modalContent) {
+ return;
+ }
+
+ return inertBackground(getModalRoot(modalContent));
+ }, [isTopmost]);
+
+ useEffect(() => {
+ if (!isTopmost) {
+ return;
+ }
+
+ const trapFocus = createFocusTrap(() => maybeGetElement(contentRef.current));
+
+ trapFocus();
+
+ return onDocumentFocus(trapFocus);
+ }, [isTopmost]);
+
+ return contentRef;
+}
diff --git a/src/hooks/useRestoreFocus.ts b/src/hooks/useRestoreFocus.ts
new file mode 100644
index 0000000..5cf9603
--- /dev/null
+++ b/src/hooks/useRestoreFocus.ts
@@ -0,0 +1,17 @@
+import { useEffect } from 'react';
+
+import { maybeGetElement } from '../focusHelpers';
+
+export function useRestoreFocus() {
+ useEffect(() => {
+ const previouslyFocused = maybeGetElement(document.activeElement);
+
+ return () => {
+ Promise.resolve().then(() => {
+ if (previouslyFocused && document.contains(previouslyFocused)) {
+ previouslyFocused.focus();
+ }
+ });
+ };
+ }, []);
+}