diff --git a/packages/@react-aria/overlays/docs/PortalProvider.mdx b/packages/@react-aria/overlays/docs/PortalProvider.mdx
new file mode 100644
index 00000000000..69b63e72fc8
--- /dev/null
+++ b/packages/@react-aria/overlays/docs/PortalProvider.mdx
@@ -0,0 +1,138 @@
+{/* Copyright 2025 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 {Layout} from '@react-spectrum/docs';
+export default Layout;
+
+import docs from 'docs:@react-aria/overlays';
+import {HeaderInfo, PropTable, FunctionAPI, PageDescription} from '@react-spectrum/docs';
+import packageData from '@react-aria/overlays/package.json';
+
+---
+category: Utilities
+keywords: [overlays, portals]
+---
+
+# PortalProvider
+
+{docs.exports.UNSAFE_PortalProvider.description}
+
+
+
+## Introduction
+
+`UNSAFE_PortalProvider` is a utility wrapper component that can be used to set where components like
+Modals, Popovers, Toasts, and Tooltips will portal their overlay element to. This is typically used when
+your app is already portalling other elements to a location other than the `document.body` and thus requires
+your React Aria components to send their overlays to the same container.
+
+Please note that `UNSAFE_PortalProvider` is considered `UNSAFE` because it is an escape hatch, and there are
+many places that an application could portal to. Not all of them will work, either with styling, accessibility,
+or for a variety of other reasons. Typically, it is best to portal to the root of the entire application, e.g. the `body` element,
+outside of any possible overflow or stacking contexts. We envision `UNSAFE_PortalProvider` being used to group all of the portalled
+elements into a single container at the root of the app or to control the order of children of the `body` element, but you may have use cases
+that need to do otherwise.
+
+## Props
+
+
+
+## Example
+
+The example below shows how you can use `UNSAFE_PortalProvider` to portal your Toasts to an arbitrary container. Note that
+the Toast in this example is taken directly from the [React Aria Components Toast documentation](Toast.html#example), please visit that page for
+a detailed explanation of its implementation.
+
+```tsx import
+import {UNSTABLE_ToastRegion as ToastRegion, UNSTABLE_Toast as Toast, UNSTABLE_ToastQueue as ToastQueue, UNSTABLE_ToastContent as ToastContent, Button, Text} from 'react-aria-components';
+
+
+// Define the type for your toast content.
+interface MyToastContent {
+ title: string,
+ description?: string
+}
+
+// Create a global ToastQueue.
+const queue = new ToastQueue();
+
+function MyToastRegion() {
+ return (
+
+ {({toast}) => (
+
+
+ {toast.content.title}
+ {toast.content.description}
+
+
+
+ )}
+
+
+ );
+}
+```
+
+```tsx example
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
+
+// See the above Toast docs link for the ToastRegion implementation
+function App() {
+ let container = React.useRef(null);
+ return (
+ <>
+ container.current}>
+
+
+
+
+ Toasts are portalled here!
+
+ >
+ );
+}
+
+
+```
+
+```css hidden
+@import '../../../react-aria-components/docs/Button.mdx' layer(button);
+@import '../../../react-aria-components/docs/Toast.mdx' layer(toast);
+@import "@react-aria/example-theme";
+
+.react-aria-ToastRegion {
+ position: unset;
+}
+```
+
+## Contexts
+
+The `getContainer` set by the nearest PortalProvider can be accessed by calling `useUNSAFE_PortalContext`. This can be
+used by custom overlay components to ensure that they are also being consistently portalled throughout your app.
+
+
+
+```tsx
+import {useUNSAFE_PortalContext} from '@react-aria/overlays';
+
+function MyOverlay(props) {
+ let {children} = props;
+ let {getContainer} = useUNSAFE_PortalContext();
+ return ReactDOM.createPortal(children, getContainer());
+}
+```
diff --git a/packages/@react-aria/overlays/src/Overlay.tsx b/packages/@react-aria/overlays/src/Overlay.tsx
index 61711422c25..ee818bcefc6 100644
--- a/packages/@react-aria/overlays/src/Overlay.tsx
+++ b/packages/@react-aria/overlays/src/Overlay.tsx
@@ -16,12 +16,13 @@ import React, {ReactNode, useContext, useMemo, useState} from 'react';
import ReactDOM from 'react-dom';
import {useIsSSR} from '@react-aria/ssr';
import {useLayoutEffect} from '@react-aria/utils';
-import {useUNSTABLE_PortalContext} from './PortalProvider';
+import {useUNSAFE_PortalContext} from './PortalProvider';
export interface OverlayProps {
/**
* The container element in which the overlay portal will be placed.
* @default document.body
+ * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
*/
portalContainer?: Element,
/** The overlay to render in the portal. */
@@ -55,8 +56,8 @@ export function Overlay(props: OverlayProps): ReactNode | null {
let [contain, setContain] = useState(false);
let contextValue = useMemo(() => ({contain, setContain}), [contain, setContain]);
- let {getContainer} = useUNSTABLE_PortalContext();
- if (!props.portalContainer && getContainer) {
+ let {getContainer} = useUNSAFE_PortalContext();
+ if (!props.portalContainer && getContainer) {
portalContainer = getContainer();
}
diff --git a/packages/@react-aria/overlays/src/PortalProvider.tsx b/packages/@react-aria/overlays/src/PortalProvider.tsx
index ab0e59a8c6e..8721a00df5f 100644
--- a/packages/@react-aria/overlays/src/PortalProvider.tsx
+++ b/packages/@react-aria/overlays/src/PortalProvider.tsx
@@ -13,15 +13,22 @@
import React, {createContext, ReactNode, useContext} from 'react';
export interface PortalProviderProps {
- /* Should return the element where we should portal to. Can clear the context by passing null. */
- getContainer?: () => HTMLElement | null
+ /** Should return the element where we should portal to. Can clear the context by passing null. */
+ getContainer?: () => HTMLElement | null,
+ /** The content of the PortalProvider. Should contain all children that want to portal their overlays to the element returned by the provided `getContainer()`. */
+ children: ReactNode
}
-export const PortalContext = createContext({});
+export interface PortalProviderContextValue extends Omit{};
-export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children: ReactNode}): ReactNode {
+export const PortalContext = createContext({});
+
+/**
+ * Sets the portal container for all overlay elements rendered by its children.
+ */
+export function UNSAFE_PortalProvider(props: PortalProviderProps): ReactNode {
let {getContainer} = props;
- let {getContainer: ctxGetContainer} = useUNSTABLE_PortalContext();
+ let {getContainer: ctxGetContainer} = useUNSAFE_PortalContext();
return (
{props.children}
@@ -29,6 +36,6 @@ export function UNSTABLE_PortalProvider(props: PortalProviderProps & {children:
);
}
-export function useUNSTABLE_PortalContext(): PortalProviderProps {
+export function useUNSAFE_PortalContext(): PortalProviderContextValue {
return useContext(PortalContext) ?? {};
}
diff --git a/packages/@react-aria/overlays/src/index.ts b/packages/@react-aria/overlays/src/index.ts
index d73f2def7eb..cf37e048e7d 100644
--- a/packages/@react-aria/overlays/src/index.ts
+++ b/packages/@react-aria/overlays/src/index.ts
@@ -19,7 +19,7 @@ export {ariaHideOutside} from './ariaHideOutside';
export {usePopover} from './usePopover';
export {useModalOverlay} from './useModalOverlay';
export {Overlay, useOverlayFocusContain} from './Overlay';
-export {UNSTABLE_PortalProvider, useUNSTABLE_PortalContext} from './PortalProvider';
+export {UNSAFE_PortalProvider, useUNSAFE_PortalContext} from './PortalProvider';
export type {AriaPositionProps, PositionAria} from './useOverlayPosition';
export type {AriaOverlayProps, OverlayAria} from './useOverlay';
@@ -30,3 +30,4 @@ export type {AriaPopoverProps, PopoverAria} from './usePopover';
export type {AriaModalOverlayProps, ModalOverlayAria} from './useModalOverlay';
export type {OverlayProps} from './Overlay';
export type {Placement, PlacementAxis, PositionProps} from '@react-types/overlays';
+export type {PortalProviderProps, PortalProviderContextValue} from './PortalProvider';
diff --git a/packages/@react-aria/overlays/src/useModal.tsx b/packages/@react-aria/overlays/src/useModal.tsx
index 5360a93cad8..5a5e8188b8a 100644
--- a/packages/@react-aria/overlays/src/useModal.tsx
+++ b/packages/@react-aria/overlays/src/useModal.tsx
@@ -14,6 +14,7 @@ import {DOMAttributes} from '@react-types/shared';
import React, {AriaAttributes, ReactNode, useContext, useEffect, useMemo, useState} from 'react';
import ReactDOM from 'react-dom';
import {useIsSSR} from '@react-aria/ssr';
+import {useUNSAFE_PortalContext} from './PortalProvider';
export interface ModalProviderProps extends DOMAttributes {
children: ReactNode
@@ -112,6 +113,7 @@ export interface OverlayContainerProps extends ModalProviderProps {
/**
* The container element in which the overlay portal will be placed.
* @default document.body
+ * @deprecated - Use a parent UNSAFE_PortalProvider to set your portal container instead.
*/
portalContainer?: Element
}
@@ -126,6 +128,10 @@ export interface OverlayContainerProps extends ModalProviderProps {
export function OverlayContainer(props: OverlayContainerProps): React.ReactPortal | null {
let isSSR = useIsSSR();
let {portalContainer = isSSR ? null : document.body, ...rest} = props;
+ let {getContainer} = useUNSAFE_PortalContext();
+ if (!props.portalContainer && getContainer) {
+ portalContainer = getContainer();
+ }
React.useEffect(() => {
if (portalContainer?.closest('[data-overlay-container]')) {
diff --git a/packages/@react-spectrum/dialog/test/DialogContainer.test.js b/packages/@react-spectrum/dialog/test/DialogContainer.test.js
index 972055d8105..4fe0a03c01d 100644
--- a/packages/@react-spectrum/dialog/test/DialogContainer.test.js
+++ b/packages/@react-spectrum/dialog/test/DialogContainer.test.js
@@ -21,7 +21,7 @@ import {Heading, Text} from '@react-spectrum/text';
import {Provider} from '@react-spectrum/provider';
import React, {useRef, useState} from 'react';
import {theme} from '@react-spectrum/theme-default';
-import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';
describe('DialogContainer', function () {
@@ -254,13 +254,13 @@ describe('DialogContainer', function () {
return (
setOpen(true)}>Open dialog
- container.current}>
+ container.current}>
setOpen(false)} {...props}>
{isOpen &&
}
-
+
);
diff --git a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js
index 15ee46ec88f..f0eb6ff8efc 100644
--- a/packages/@react-spectrum/dialog/test/DialogTrigger.test.js
+++ b/packages/@react-spectrum/dialog/test/DialogTrigger.test.js
@@ -21,7 +21,7 @@ import {Provider} from '@react-spectrum/provider';
import React from 'react';
import {TextField} from '@react-spectrum/textfield';
import {theme} from '@react-spectrum/theme-default';
-import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';
@@ -1031,12 +1031,12 @@ describe('DialogTrigger', function () {
let {container} = props;
return (
- container.current}>
+ container.current}>
Trigger
-
+
);
}
diff --git a/packages/@react-spectrum/menu/test/MenuTrigger.test.js b/packages/@react-spectrum/menu/test/MenuTrigger.test.js
index 2e6752da3b0..827aa598dd1 100644
--- a/packages/@react-spectrum/menu/test/MenuTrigger.test.js
+++ b/packages/@react-spectrum/menu/test/MenuTrigger.test.js
@@ -29,7 +29,7 @@ import {Link} from '@react-spectrum/link';
import {Provider} from '@react-spectrum/provider';
import React from 'react';
import {theme} from '@react-spectrum/theme-default';
-import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import {User} from '@react-aria/test-utils';
import userEvent from '@testing-library/user-event';
@@ -735,7 +735,7 @@ describe('MenuTrigger', function () {
function InfoMenu(props) {
return (
- props.container.current}>
+ props.container.current}>
-
+
);
}
diff --git a/packages/@react-spectrum/table/src/Resizer.tsx b/packages/@react-spectrum/table/src/Resizer.tsx
index c80cb5b26c0..defc24868ea 100644
--- a/packages/@react-spectrum/table/src/Resizer.tsx
+++ b/packages/@react-spectrum/table/src/Resizer.tsx
@@ -1,4 +1,4 @@
-
+
import {classNames} from '@react-spectrum/utils';
import {ColumnSize} from '@react-types/table';
import eCursor from 'bundle-text:./cursors/Cur_MoveToRight_9_9.svg';
@@ -16,7 +16,7 @@ import {TableColumnResizeState} from '@react-stately/table';
import {useLocale, useLocalizedStringFormatter} from '@react-aria/i18n';
import {useTableColumnResize} from '@react-aria/table';
import {useTableContext, useVirtualizerContext} from './TableViewBase';
-import {useUNSTABLE_PortalContext} from '@react-aria/overlays';
+import {useUNSAFE_PortalContext} from '@react-aria/overlays';
// @ts-ignore
import wCursor from 'bundle-text:./cursors/Cur_MoveToLeft_9_9.svg';
@@ -132,6 +132,6 @@ export const Resizer = React.forwardRef(function Resizer(props: ResizerProps<
function CursorOverlay(props) {
let {show, children} = props;
- let {getContainer} = useUNSTABLE_PortalContext();
+ let {getContainer} = useUNSAFE_PortalContext();
return show ? ReactDOM.createPortal(children, getContainer?.() ?? document.body) : null;
}
diff --git a/packages/@react-spectrum/table/test/TableSizing.test.tsx b/packages/@react-spectrum/table/test/TableSizing.test.tsx
index 7a36427b394..cddb75e8ea6 100644
--- a/packages/@react-spectrum/table/test/TableSizing.test.tsx
+++ b/packages/@react-spectrum/table/test/TableSizing.test.tsx
@@ -26,7 +26,7 @@ import {resizingTests} from '@react-aria/table/test/tableResizingTests';
import {Scale} from '@react-types/provider';
import {setInteractionModality} from '@react-aria/interactions';
import {theme} from '@react-spectrum/theme-default';
-import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import userEvent from '@testing-library/user-event';
let columns = [
@@ -1048,7 +1048,7 @@ describe('TableViewSizing', function () {
let Example = (props) => {
let container = useRef(null);
return (
- container.current}>
+ container.current}>
Foo
@@ -1064,7 +1064,7 @@ describe('TableViewSizing', function () {
-
+
);
};
let customPortalRender = (props) => render();
diff --git a/packages/@react-spectrum/toast/src/Toaster.tsx b/packages/@react-spectrum/toast/src/Toaster.tsx
index 356fc97fc4c..0fb2ae7ad2e 100644
--- a/packages/@react-spectrum/toast/src/Toaster.tsx
+++ b/packages/@react-spectrum/toast/src/Toaster.tsx
@@ -20,7 +20,7 @@ import ReactDOM from 'react-dom';
import toastContainerStyles from './toastContainer.css';
import type {ToastPlacement} from './ToastContainer';
import {ToastState} from '@react-stately/toast';
-import {useUNSTABLE_PortalContext} from '@react-aria/overlays';
+import {useUNSAFE_PortalContext} from '@react-aria/overlays';
interface ToastContainerProps extends AriaToastRegionProps {
children: ReactNode,
@@ -39,7 +39,7 @@ export function Toaster(props: ToastContainerProps): ReactElement {
let ref = useRef(null);
let {regionProps} = useToastRegion(props, state, ref);
let {focusProps, isFocusVisible} = useFocusRing();
- let {getContainer} = useUNSTABLE_PortalContext();
+ let {getContainer} = useUNSAFE_PortalContext();
let [position, placement] = useMemo(() => {
let [pos = 'bottom', place = 'center'] = props.placement?.split(' ') || [];
diff --git a/packages/@react-spectrum/toast/stories/Toast.stories.tsx b/packages/@react-spectrum/toast/stories/Toast.stories.tsx
index 9e7d0cb26e1..a4e1e697151 100644
--- a/packages/@react-spectrum/toast/stories/Toast.stories.tsx
+++ b/packages/@react-spectrum/toast/stories/Toast.stories.tsx
@@ -21,8 +21,8 @@ import {Heading} from '@react-spectrum/text';
import React, {SyntheticEvent, useEffect, useMemo, useRef, useState} from 'react';
import {SpectrumToastOptions, ToastPlacement} from '../src/ToastContainer';
import {ToastContainer, ToastQueue} from '../';
+import {UNSAFE_PortalProvider} from '@react-aria/overlays';
import {UNSTABLE_createLandmarkController, useLandmark} from '@react-aria/landmark';
-import {UNSTABLE_PortalProvider} from '@react-aria/overlays';
export default {
title: 'Toast',
@@ -384,11 +384,11 @@ function FullscreenApp(props) {
}, []);
return (