diff --git a/packages/react-aria-components/test/CloseWatcher.browser.test.tsx b/packages/react-aria-components/test/CloseWatcher.browser.test.tsx
new file mode 100644
index 00000000000..a0805ad49d6
--- /dev/null
+++ b/packages/react-aria-components/test/CloseWatcher.browser.test.tsx
@@ -0,0 +1,390 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {Autocomplete} from '../src/Autocomplete';
+import {Button} from '../src/Button';
+import {Checkbox} from '../src/Checkbox';
+import {describe, expect, it} from 'vitest';
+import {Dialog, DialogTrigger} from '../src/Dialog';
+import {GridList, GridListItem} from '../src/GridList';
+import {Heading} from '../src/Heading';
+import {Input} from '../src/Input';
+import {Menu, MenuItem, MenuTrigger, SubmenuTrigger} from '../src/Menu';
+import {Modal, ModalOverlay} from '../src/Modal';
+import {Popover} from '../src/Popover';
+import React from 'react';
+import {render} from 'vitest-browser-react';
+import {SearchField} from '../src/SearchField';
+import {useFilter} from 'react-aria/useFilter';
+import {User} from '@react-aria/test-utils';
+import {userEvent} from 'vitest/browser';
+
+async function press(key: string) {
+ await userEvent.keyboard(`{${key}}`);
+}
+
+describe('CloseWatcher dismissal', () => {
+ it('closes a single modal on Escape', async () => {
+ let screen = await render(
+
+
+
+
+
+
+ );
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+ await press('Escape');
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('does not close when isKeyboardDismissDisabled', async () => {
+ let screen = await render(
+
+
+
+
+
+
+
+
+ );
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+ await press('Escape');
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('closes the top-most overlay first for nested modals', async () => {
+ // Outer opens on mount (the single "free" watcher). The inner modal is
+ // opened by a click so it gets its own transient-user-activation watcher
+ // and is NOT grouped with the outer (grouping would close both at once).
+ let screen = await render(
+
+
+
+
+
+
+ );
+ await expect.element(screen.getByRole('button', {name: 'Open inner'})).toBeInTheDocument();
+ let outerDialog = screen.getByRole('dialog');
+ let outerButton = screen.getByRole('button', {name: 'Open inner'});
+ await userEvent.click(outerButton);
+ await expect.element(screen.getByRole('button', {name: 'Inner focus'})).toBeInTheDocument();
+ let innerButton = screen.getByRole('button', {name: 'Inner focus'});
+ await press('Escape');
+ // Inner closed, outer remains.
+ await expect.element(innerButton).not.toBeInTheDocument();
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+ await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(1);
+ // Focus restoration to the trigger is async (focus briefly falls to
+ // before FocusScope restores it). Wait for focus to return to the outer trigger
+ // otherwise Escape will fire on and never reach the overlay.
+ await expect.poll(() => document.activeElement).toBe(outerButton.element());
+ await press('Escape');
+ // Outer closes on the replacement/next watcher.
+ await expect.element(outerDialog).not.toBeInTheDocument();
+ });
+
+ it('closes an inner popover before the modal', async () => {
+ // Modal opens on mount (free watcher); popover opened via click so its
+ // watcher is independent (not grouped) and receives the first close request.
+ let screen = await render(
+
+
+
+
+ Modal
+
+
+
+
+
+
+
+
+
+
+
+ );
+ let popoverTrigger = screen.getByRole('button', {name: 'Open popover'});
+ await userEvent.click(popoverTrigger);
+ await expect.element(screen.getByText('Popover content')).toBeInTheDocument();
+ await press('Escape');
+ // Popover (most-recent watcher) closes; modal stays.
+ await expect.element(screen.getByText('Popover content')).not.toBeInTheDocument();
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await expect.poll(() => document.activeElement).toBe(popoverTrigger.element());
+ await press('Escape');
+ // Modal closes on the next close request.
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('recreates a watcher when a controlled parent ignores onOpenChange', async () => {
+ let closeCount = 0;
+ function Controlled() {
+ let [isOpen, setIsOpen] = React.useState(true);
+ // Always open; ignore onOpenChange(false) so the modal stays open.
+ return (
+ {
+ if (!isOpen && closeCount < 1) {
+ closeCount++;
+ return;
+ }
+ setIsOpen(isOpen);
+ }}>
+
+
+
+ Sticky
+
+
+
+
+ );
+ }
+ let screen = await render();
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+
+ await press('Escape');
+ await expect.poll(() => closeCount).toBe(1);
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Second Escape only fires if a replacement watcher was created.
+ await press('Escape');
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('a non-dismissable modal on top blocks Escape from closing the modal beneath it', async () => {
+ // Outer (dismissable) opens on mount; inner (isKeyboardDismissDisabled) is
+ // opened on top. Escape must NOT close the outer modal past the inner one.
+ let screen = await render(
+
+
+
+
+ Outer
+
+
+
+
+
+ Inner
+
+
+
+
+
+
+
+
+ );
+ await userEvent.click(screen.getByRole('button', {name: 'Open inner'}));
+ await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(2);
+ let innerContent = screen.getByRole('button', {name: 'Inner focus'});
+ await expect.poll(() => document.activeElement).toBe(innerContent.element());
+
+ // Escape does nothing: the non-dismissable inner modal absorbs/blocks the
+ // close request, so neither modal closes.
+ await press('Escape');
+ await press('Escape');
+ await expect.poll(() => screen.getByRole('dialog').elements().length).toBe(2);
+ });
+
+ it('Escape clears the autocomplete input, then closes the popover, then the dialog', async () => {
+ function Example() {
+ let {contains} = useFilter({sensitivity: 'base'});
+ return (
+
+
+
+
+ Dialog
+
+
+
+ contains(t, i)}>
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ let screen = await render();
+ let popoverTrigger = screen.getByRole('button', {name: 'Open popover'});
+ await userEvent.click(popoverTrigger);
+ let searchbox = screen.getByRole('searchbox');
+ await expect.element(searchbox).toBeInTheDocument();
+ await userEvent.type(searchbox, 'Fo');
+ await expect.element(searchbox).toHaveValue('Fo');
+
+ // Esc #1: the autocomplete/search field consumes Escape to clear its input.
+ // The popover (and dialog) stay open, and the close request is suppressed.
+ await press('Escape');
+ await expect.element(searchbox).toHaveValue('');
+ await expect.element(searchbox).toBeInTheDocument();
+
+ // Esc #2: with an empty input, Escape closes the popover. The modal remains.
+ await press('Escape');
+ await expect.element(screen.getByRole('searchbox')).not.toBeInTheDocument();
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Wait for focus to return to the popover trigger (async) so the fallback
+ // focus-bound Escape reaches the modal, then Esc #3 closes the dialog.
+ await expect.poll(() => document.activeElement).toBe(popoverTrigger.element());
+ await press('Escape');
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('Escape clears the GridList selection, then closes the dialog', async () => {
+ let selection = new Set();
+ function Example() {
+ return (
+
+
+
+
+ List
+ {
+ selection = keys as Set;
+ }}>
+
+
+ One
+
+
+
+ Two
+
+
+
+ Three
+
+
+
+
+
+ );
+ }
+ let testUtilUser = new User();
+ let screen = await render();
+ await expect.element(screen.getByRole('grid')).toBeInTheDocument();
+ let grid = screen.getByRole('grid').element() as HTMLElement;
+ let tester = testUtilUser.createTester('GridList', {
+ root: grid,
+ layout: 'grid',
+ interactionType: 'keyboard'
+ });
+ let rows = tester.getRows();
+ await tester.toggleRowSelection({row: rows[0]});
+ await tester.toggleRowSelection({row: rows[1]});
+ await expect.poll(() => selection.size).toBe(2);
+
+ // Esc #1: the GridList consumes Escape to clear its selection. Dialog stays.
+ await press('Escape');
+ await expect.poll(() => selection.size).toBe(0);
+ await expect.element(screen.getByRole('dialog')).toBeInTheDocument();
+
+ // Esc #2: with no selection, Escape closes the dialog.
+ await press('Escape');
+ await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('Escape closes one submenu level at a time (3 deep)', async () => {
+ function Example() {
+ return (
+
+
+
+
+
+
+ );
+ }
+ let menuCount = () => document.querySelectorAll('[role="menu"]').length;
+ let testUtilUser = new User();
+ let screen = await render();
+ let menuTester = testUtilUser.createTester('Menu', {
+ root: screen.getByRole('button', {name: 'Menu'}).element() as HTMLElement,
+ interactionType: 'keyboard'
+ });
+ await menuTester.open();
+ let l2 = await menuTester.openSubmenu({submenuTrigger: menuTester.getSubmenuTriggers()[0]});
+ await l2.openSubmenu({submenuTrigger: l2.getSubmenuTriggers()[0]});
+ await expect.poll(menuCount).toBe(3);
+
+ // Each Escape closes only the deepest submenu, restoring focus to its trigger.
+ await press('Escape');
+ await expect.poll(menuCount).toBe(2);
+ await press('Escape');
+ await expect.poll(menuCount).toBe(1);
+ await press('Escape');
+ await expect.poll(menuCount).toBe(0);
+ await expect
+ .poll(() => document.activeElement)
+ .toBe(screen.getByRole('button', {name: 'Menu'}).element());
+ });
+});
diff --git a/packages/react-aria/src/overlays/closeWatchers.ts b/packages/react-aria/src/overlays/closeWatchers.ts
new file mode 100644
index 00000000000..812607ed64e
--- /dev/null
+++ b/packages/react-aria/src/overlays/closeWatchers.ts
@@ -0,0 +1,139 @@
+/*
+ * Copyright 2026 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+import {RefObject} from '@react-types/shared';
+import {visibleOverlays} from './useOverlay';
+
+// CloseWatcher is not yet in the TypeScript DOM lib (TS 5.8.2)
+interface CloseWatcher {
+ destroy(): void;
+ onclose: (() => void) | null;
+}
+interface CloseWatcherConstructor {
+ new (): CloseWatcher;
+}
+declare global {
+ interface Window {
+ CloseWatcher?: CloseWatcherConstructor;
+ }
+}
+
+// CloseWatchers are an interesting API for web development. You may not be familiar with them
+// https://developer.mozilla.org/en-US/docs/Web/API/CloseWatcher
+// https://html.spec.whatwg.org/multipage/interaction.html#close-requests-and-close-watchers
+// The quick synopsis is that they are EventTargets which emit cancel->close events and also
+// call an `onclose` property. CloseWatchers will disconnect and be destroyed after they close.
+// They are in a browser managed stack in terms of which one gets called for a given event.
+// This stack isn't straightforward though, it contains "groupings", and one gesture/event may
+// trigger multiple close watchers to fire synchronously.
+
+// We create a singleton which manages subscriptions from overlays that want to use CloseWatchers.
+// It creates/destroys CloseWatchers as needed to match the number of subscribers.
+// When a CloseWatcher fires, we determine which subscriber/overlay is the top-most and call its onClose.
+// There is a weird edge case that we handle for controlled overlay triggers. If an overlay is
+// controlled open and tries to close, but the parent ignores the onOpenChange, then the
+// CloseWatcher will have been destroyed, but the total number of subscribers has not changed.
+// In this case, we need to reconcile that difference and create a new CloseWatcher.
+
+export interface CloseWatcherSubscriber {
+ /** The overlay container ref, used to determine react aria's stacking order via visibleOverlays. */
+ ref: RefObject;
+ /** Called when this overlay should close because it is the top-most. */
+ onClose: () => void;
+}
+
+const subscribers = new Set();
+const watchers: CloseWatcher[] = [];
+
+// Refs asked to close during the current (synchronous) close request. Excluded
+// from the top-most calculation so grouped synchronous fires close distinct
+// overlays. Cleared in the next microtask.
+const pendingClose = new Set>();
+let pendingClear = false;
+
+function hasCloseWatcher(): boolean {
+ return typeof window !== 'undefined' && typeof window.CloseWatcher === 'function';
+}
+
+function createWatcher(): void {
+ let watcher = new window.CloseWatcher!();
+ watcher.onclose = () => {
+ let i = watchers.indexOf(watcher);
+ if (i >= 0) {
+ watchers.splice(i, 1);
+ }
+ try {
+ notify();
+ } finally {
+ reconcile();
+ }
+ };
+ watchers.push(watcher);
+}
+
+// Invariant: watchers.length === subscribers.size.
+// There may be a troublesome case here, creating them this way means they won't be
+// "trusted" and may be "grouped" with other watchers and cause too many
+// overlays to close at once. I'm unsure how to test this... but the spec seems to
+// suggest this could happen.
+function reconcile(): void {
+ if (!hasCloseWatcher()) {
+ return;
+ }
+ while (watchers.length < subscribers.size) {
+ createWatcher();
+ }
+ while (watchers.length > subscribers.size) {
+ watchers.pop()!.destroy();
+ }
+}
+
+// Question about this, should all overlays close synchronously? Or should it be one at a time?
+// When CloseWatchers are "grouped", they will all fire synchronously, but our current hooks
+// may not like that. Either overlays have closed one at a time, or the parent has closed and unmounted
+// all of them. I'm not sure if there is a race to worry about if they all try to close individually.
+function notify(): void {
+ // Snapshot at the start so mutations (closing removes from visibleOverlays)
+ // and synchronous unsubscribes do not shift the top-most check mid-pass.
+ let overlays = visibleOverlays.filter(o => !pendingClose.has(o));
+ let topMost = overlays[overlays.length - 1];
+ if (!topMost) {
+ return;
+ }
+
+ pendingClose.add(topMost);
+ if (!pendingClear) {
+ pendingClear = true;
+ queueMicrotask(() => {
+ pendingClose.clear();
+ pendingClear = false;
+ });
+ }
+
+ // Only the top-most one closes.
+ for (let subscriber of [...subscribers]) {
+ if (subscriber.ref === topMost) {
+ subscriber.onClose();
+ }
+ }
+}
+
+export function subscribeCloseWatcher(subscriber: CloseWatcherSubscriber): () => void {
+ if (!hasCloseWatcher()) {
+ return () => {};
+ }
+ subscribers.add(subscriber);
+ reconcile();
+ return () => {
+ subscribers.delete(subscriber);
+ reconcile();
+ };
+}
diff --git a/packages/react-aria/src/overlays/useOverlay.ts b/packages/react-aria/src/overlays/useOverlay.ts
index 1f2f476f4f8..e1c1af54fdb 100644
--- a/packages/react-aria/src/overlays/useOverlay.ts
+++ b/packages/react-aria/src/overlays/useOverlay.ts
@@ -13,7 +13,9 @@
import {DOMAttributes, RefObject} from '@react-types/shared';
import {getEventTarget} from '../utils/shadowdom/DOMFunctions';
import {isElementInChildOfActiveScope} from '../focus/FocusScope';
+import {subscribeCloseWatcher} from './closeWatchers';
import {useEffect, useRef} from 'react';
+import {useEffectEvent} from '../utils/useEffectEvent';
import {useFocusWithin} from '../interactions/useFocusWithin';
import {useInteractOutside} from '../interactions/useInteractOutside';
import {useKeyboard} from '../interactions/useKeyboard';
@@ -58,7 +60,7 @@ export interface OverlayAria {
underlayProps: DOMAttributes;
}
-const visibleOverlays: RefObject[] = [];
+export const visibleOverlays: RefObject[] = [];
/**
* Provides the behavior for overlays such as dialogs, popovers, and menus.
@@ -97,6 +99,17 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject onClose?.());
+ useEffect(() => {
+ if (!isOpen || isKeyboardDismissDisabled || !hasCloseWatcher) {
+ return;
+ }
+ // Should *every* overlay be closed by close watcher? should some just be Esc? "Tooltip" for example?
+ return subscribeCloseWatcher({ref, onClose: onCloseRequest});
+ }, [isOpen, isKeyboardDismissDisabled, hasCloseWatcher, ref]);
+
let onInteractOutsideStart = (e: PointerEvent) => {
const topMostOverlay = visibleOverlays[visibleOverlays.length - 1];
lastVisibleOverlay.current = topMostOverlay;
@@ -125,18 +138,25 @@ export function useOverlay(props: AriaOverlayProps, ref: RefObject {
- if (!isKeyboardDismissDisabled) {
- onHide();
- return;
+ // Fallback Escape handling for browsers without CloseWatcher support.
+ // When CloseWatcher is available, Escape is handled via the subscription
+ // above and we must NOT preventDefault here or the native close request
+ // would be suppressed.
+ let {keyboardProps} = useKeyboard(
+ hasCloseWatcher
+ ? {}
+ : {
+ shortcuts: {
+ Escape: () => {
+ if (!isKeyboardDismissDisabled) {
+ onHide();
+ return;
+ }
+ return false;
+ }
+ }
}
- return false;
- }
- }
- });
+ );
// Handle clicking outside the overlay to close it
useInteractOutside({