diff --git a/.changeset/stripe-third-party-overlays.md b/.changeset/stripe-third-party-overlays.md
new file mode 100644
index 00000000..929ba392
--- /dev/null
+++ b/.changeset/stripe-third-party-overlays.md
@@ -0,0 +1,5 @@
+---
+"@easypost/easy-ui": minor
+---
+
+Add `allowsThirdPartyOverlays` to `Modal.Trigger` and `ModalContainer`. When enabled, the modal stops trapping focus and stops applying `aria-hidden`/`inert` to the rest of the page, so third-party overlays that render outside the modal (e.g. Stripe Link/autofill, reCAPTCHA) remain focusable and clickable instead of locking up.
diff --git a/easy-ui-react/src/Modal/Modal.mdx b/easy-ui-react/src/Modal/Modal.mdx
index 4e30ef3f..dc88af15 100644
--- a/easy-ui-react/src/Modal/Modal.mdx
+++ b/easy-ui-react/src/Modal/Modal.mdx
@@ -115,6 +115,24 @@ A `` accepts a single `` as a child. If no child is p
+## Third-party overlays
+
+By default, a `` traps focus and hides the rest of the page from assistive technologies (via `aria-hidden`/`inert`) while it's open. This is the correct behavior for most modals, but it breaks third-party widgets that inject their own overlays into the document _outside_ the modal—e.g. Stripe Link/autofill or reCAPTCHA. Those overlays get `inert`'d (making them unclickable) and lose focus back to the modal.
+
+Set `allowsThirdPartyOverlays` on `` or `` to opt out of focus trapping and background aria-hiding so those overlays stay usable. Closing on interaction outside, scroll locking, and restoring focus to the trigger on close are all preserved.
+
+Use this sparingly. It trades away the modal's focus containment and background hiding, so reach for it only when a modal must host a third-party overlay that requires it.
+
+```tsx
+
+
+
+ New Credit Card
+ {/* Stripe CardElement, etc. */}
+
+
+```
+
## Properties
### Modal.Trigger
diff --git a/easy-ui-react/src/Modal/Modal.test.tsx b/easy-ui-react/src/Modal/Modal.test.tsx
index 4253f655..e11a12ba 100644
--- a/easy-ui-react/src/Modal/Modal.test.tsx
+++ b/easy-ui-react/src/Modal/Modal.test.tsx
@@ -202,6 +202,16 @@ describe("", () => {
);
expect(handleSecondaryAction).toBeCalled();
});
+
+ it("should hide outside content while open by default", () => {
+ renderModalWithOutsideContent();
+ expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(true);
+ });
+
+ it("should not hide outside content when allowsThirdPartyOverlays is set", () => {
+ renderModalWithOutsideContent({ allowsThirdPartyOverlays: true });
+ expect(isElementHidden(screen.getByTestId("outside-content"))).toBe(false);
+ });
});
const CustomSymbol = (props: object) => ;
@@ -270,3 +280,31 @@ async function renderAndOpenModal(args = {}) {
expect(screen.queryByRole("dialog")).toBeInTheDocument();
return response;
}
+
+// react-aria's `ariaHideOutside` hides outside content via `inert` (or
+// `aria-hidden` where `inert` is unsupported), applied to an ancestor.
+function isElementHidden(element: Element) {
+ return Boolean(
+ element.closest("[inert]") || element.closest('[aria-hidden="true"]'),
+ );
+}
+
+function renderModalWithOutsideContent({
+ allowsThirdPartyOverlays,
+}: Partial = {}) {
+ return render(
+ <>
+
Outside content
+
+
+
+ Header
+ Content
+
+
+ >,
+ );
+}
diff --git a/easy-ui-react/src/Modal/ModalContainer.tsx b/easy-ui-react/src/Modal/ModalContainer.tsx
index 741c4032..502a417f 100644
--- a/easy-ui-react/src/Modal/ModalContainer.tsx
+++ b/easy-ui-react/src/Modal/ModalContainer.tsx
@@ -15,6 +15,15 @@ type ModalContainerProps = {
*/
isDismissable?: boolean;
+ /**
+ * Disables focus trapping and background aria-hiding so the modal can host
+ * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render
+ * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs.
+ *
+ * @default false
+ */
+ allowsThirdPartyOverlays?: boolean;
+
/**
* Handler that is called when the overlay is closed.
*/
@@ -30,7 +39,12 @@ type ModalContainerProps = {
* element or when the trigger unmounts while the modal is open.
*/
export function ModalContainer(props: ModalContainerProps) {
- const { children, isDismissable = true, onDismiss = () => {} } = props;
+ const {
+ children,
+ isDismissable = true,
+ allowsThirdPartyOverlays = false,
+ onDismiss = () => {},
+ } = props;
const childArray = React.Children.toArray(children);
if (childArray.length > 1) {
@@ -65,7 +79,11 @@ export function ModalContainer(props: ModalContainerProps) {
return (
{state.isOpen && (
-
+
{lastChild ? cloneElement(lastChild, overlayProps) : null}
)}
diff --git a/easy-ui-react/src/Modal/ModalTrigger.tsx b/easy-ui-react/src/Modal/ModalTrigger.tsx
index d603584f..d3256abd 100644
--- a/easy-ui-react/src/Modal/ModalTrigger.tsx
+++ b/easy-ui-react/src/Modal/ModalTrigger.tsx
@@ -19,9 +19,20 @@ export type ModalTriggerProps = {
/**
* Whether or not the modal can be dismissed.
+ *
+ * @default true
*/
isDismissable?: boolean;
+ /**
+ * Disables focus trapping and background aria-hiding so the modal can host
+ * third-party overlays (e.g. Stripe Link/autofill, reCAPTCHA) that render
+ * outside the modal. Use sparingly — see `ModalUnderlay` for the tradeoffs.
+ *
+ * @default false
+ */
+ allowsThirdPartyOverlays?: boolean;
+
/**
* Whether the modal is open by default (controlled).
*/
diff --git a/easy-ui-react/src/Modal/ModalUnderlay.tsx b/easy-ui-react/src/Modal/ModalUnderlay.tsx
index ce65c4c8..912b7dfe 100644
--- a/easy-ui-react/src/Modal/ModalUnderlay.tsx
+++ b/easy-ui-react/src/Modal/ModalUnderlay.tsx
@@ -1,5 +1,11 @@
-import React, { ReactNode } from "react";
-import { Overlay, useModalOverlay } from "react-aria";
+import React, { ReactNode, RefObject, useRef } from "react";
+import {
+ Overlay,
+ useModalOverlay,
+ useOverlay,
+ usePreventScroll,
+} from "react-aria";
+import { DOMAttributes } from "@react-types/shared";
import { OverlayTriggerState } from "react-stately";
import { classNames } from "../utilities/css";
import { useModalTriggerContext } from "./context";
@@ -21,22 +27,115 @@ type ModalUnderlayProps = {
* Whether or not the modal is dismissable.
*/
isDismissable?: boolean;
+
+ /**
+ * When `true`, the modal stops trapping focus and stops hiding the rest of
+ * the page from assistive technologies (`aria-hidden`/`inert`). Use this only
+ * for modals that intentionally host third-party overlays — e.g. Stripe
+ * Link/autofill or reCAPTCHA — which render themselves into the document
+ * *outside* the modal. react-aria's focus trap and `inert` would otherwise
+ * blur and lock up those overlays. This trades away the modal's focus
+ * containment and background hiding, so reach for it only when a third-party
+ * overlay requires it.
+ *
+ * @default false
+ */
+ allowsThirdPartyOverlays?: boolean;
+};
+
+type ModalUnderlayContentProps = {
+ modalProps: DOMAttributes;
+ underlayProps: DOMAttributes;
+ modalRef: RefObject;
+ children: ReactNode;
};
export function ModalUnderlay(props: ModalUnderlayProps) {
- const { state, children, ...overlayProps } = props;
- const { isDismissable = true } = overlayProps;
+ // Branch into sibling components so each calls its hooks unconditionally.
+ return props.allowsThirdPartyOverlays ? (
+
+ ) : (
+
+ );
+}
+
+/**
+ * Standard modal behavior: traps focus and hides the rest of the page via
+ * react-aria's `useModalOverlay` (which applies `aria-hidden`/`inert` outside
+ * the modal).
+ */
+function FocusTrappingUnderlay(props: ModalUnderlayProps) {
+ const { state, children, isDismissable = true } = props;
- const ref = React.useRef(null);
+ const ref = useRef(null);
const { modalProps, underlayProps } = useModalOverlay(
{
- ...overlayProps,
- isDismissable: isDismissable,
+ isDismissable,
isKeyboardDismissDisabled: !isDismissable,
},
state,
ref,
);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Like `FocusTrappingUnderlay`, but built from the lower-level overlay hooks so
+ * it can omit the two behaviors that fight third-party overlays:
+ * - no `ariaHideOutside`, so overlays injected outside the modal aren't
+ * `inert`'d (which would make them unclickable), and
+ * - no forced focus containment, so focus can't be stolen back from them.
+ *
+ * `Overlay` still restores focus to the trigger when the modal closes.
+ * close-on-interact-outside is preserved; react-aria already ignores clicks on
+ * `[data-react-aria-top-layer]` elements, so a properly tagged third-party
+ * overlay won't dismiss the modal.
+ */
+function ThirdPartyOverlayUnderlay(props: ModalUnderlayProps) {
+ const { state, children, isDismissable = true } = props;
+
+ const ref = useRef(null);
+ const { overlayProps, underlayProps } = useOverlay(
+ {
+ isOpen: state.isOpen,
+ onClose: state.close,
+ isDismissable,
+ isKeyboardDismissDisabled: !isDismissable,
+ },
+ ref,
+ );
+ usePreventScroll({ isDisabled: !state.isOpen });
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * Shared underlay markup for both underlay variants. The variants differ only
+ * in which react-aria hooks produce `modalProps`/`underlayProps`.
+ */
+function ModalUnderlayContent({
+ modalProps,
+ underlayProps,
+ modalRef,
+ children,
+}: ModalUnderlayContentProps) {
const { hasOpenNestedModal } = useModalTriggerContext();
const className = classNames(
@@ -47,7 +146,7 @@ export function ModalUnderlay(props: ModalUnderlayProps) {
return (