diff --git a/.changeset/wet-phones-camp.md b/.changeset/wet-phones-camp.md
new file mode 100644
index 00000000000..a8fc88272a7
--- /dev/null
+++ b/.changeset/wet-phones-camp.md
@@ -0,0 +1,3 @@
+---
+'@clerk/shared': patch
+---
diff --git a/packages/astro/src/react/index.ts b/packages/astro/src/react/index.ts
index c16086cc435..eb5f40d788c 100644
--- a/packages/astro/src/react/index.ts
+++ b/packages/astro/src/react/index.ts
@@ -8,6 +8,7 @@ import { SubscriptionDetailsButton, type SubscriptionDetailsButtonProps } from '
export * from './uiComponents';
export * from './controlComponents';
export * from './hooks';
+export { UNSAFE_PortalProvider } from '@clerk/shared/react';
export { SignInButton, SignOutButton, SignUpButton };
export {
SubscriptionDetailsButton as __experimental_SubscriptionDetailsButton,
diff --git a/packages/expo/src/index.ts b/packages/expo/src/index.ts
index 3020bc4407f..d3d6a591b0c 100644
--- a/packages/expo/src/index.ts
+++ b/packages/expo/src/index.ts
@@ -17,6 +17,7 @@ export { getClerkInstance } from './provider/singleton';
export * from './provider/ClerkProvider';
export * from './hooks';
export * from './components';
+export { UNSAFE_PortalProvider } from '@clerk/react';
// Override Clerk React error thrower to show that errors come from @clerk/expo
setErrorThrowerOptions({ packageName: PACKAGE_NAME });
diff --git a/packages/nextjs/src/client-boundary/controlComponents.ts b/packages/nextjs/src/client-boundary/controlComponents.ts
index 1ab240a18f5..ec526c7d623 100644
--- a/packages/nextjs/src/client-boundary/controlComponents.ts
+++ b/packages/nextjs/src/client-boundary/controlComponents.ts
@@ -15,6 +15,7 @@ export {
AuthenticateWithRedirectCallback,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
+ UNSAFE_PortalProvider,
} from '@clerk/react';
export { MultisessionAppSupport } from '@clerk/react/internal';
diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts
index b9c24e9b7ce..8419a156b47 100644
--- a/packages/nextjs/src/index.ts
+++ b/packages/nextjs/src/index.ts
@@ -8,6 +8,7 @@ export {
ClerkFailed,
ClerkLoaded,
ClerkLoading,
+ UNSAFE_PortalProvider,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
RedirectToSignIn,
diff --git a/packages/nuxt/src/runtime/components/index.ts b/packages/nuxt/src/runtime/components/index.ts
index 61bde896c00..0f4326f7071 100644
--- a/packages/nuxt/src/runtime/components/index.ts
+++ b/packages/nuxt/src/runtime/components/index.ts
@@ -25,4 +25,5 @@ export {
SignOutButton,
SignInWithMetamaskButton,
PricingTable,
+ UNSAFE_PortalProvider,
} from '@clerk/vue';
diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts
index e6e89242bfb..a63e39894f7 100644
--- a/packages/react-router/src/client/index.ts
+++ b/packages/react-router/src/client/index.ts
@@ -1,3 +1,4 @@
export * from './ReactRouterClerkProvider';
export type { WithClerkState } from './types';
export { SignIn, SignUp, OrganizationProfile, UserProfile } from './uiComponents';
+export { UNSAFE_PortalProvider } from '@clerk/react';
diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx
index 70ace96b3af..b8eee8d44bc 100644
--- a/packages/react/src/components/withClerk.tsx
+++ b/packages/react/src/components/withClerk.tsx
@@ -1,3 +1,4 @@
+import { usePortalRoot } from '@clerk/shared/react';
import type { LoadedClerk, Without } from '@clerk/shared/types';
import React from 'react';
@@ -19,6 +20,7 @@ export const withClerk =
(
useAssertWrappedByClerkProvider(displayName || 'withClerk');
const clerk = useIsomorphicClerkContext();
+ const getContainer = usePortalRoot();
if (!clerk.loaded && !options?.renderWhileLoading) {
return null;
@@ -26,6 +28,7 @@ export const withClerk =
(
return (
HTMLElement | null;
+}>;
+
+const [PortalContext, , usePortalContextWithoutGuarantee] = createContextAndHook<{
+ getContainer: () => HTMLElement | null;
+}>('PortalProvider');
+
+/**
+ * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements
+ * (popovers, modals, tooltips, etc.) that use portals.
+ *
+ * Only components within this provider will be affected. Components outside the provider
+ * will continue to use the default document.body for portals.
+ *
+ * This is particularly useful when using Clerk components inside external UI libraries
+ * like Radix Dialog or React Aria Components, where portaled elements need to render
+ * within the dialog's container to remain interactable.
+ *
+ * @example
+ * ```tsx
+ * function Example() {
+ * const containerRef = useRef(null);
+ * return (
+ *
+ * containerRef.current}>
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProviderProps) => {
+ const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]);
+
+ return {children};
+};
+
+/**
+ * Hook to get the current portal root container.
+ * Returns the getContainer function from context if inside a PortalProvider,
+ * otherwise returns a function that returns null (default behavior).
+ */
+export const usePortalRoot = (): (() => HTMLElement | null) => {
+ const contextValue = usePortalContextWithoutGuarantee();
+
+ if (contextValue && 'getContainer' in contextValue && contextValue.getContainer) {
+ return contextValue.getContainer;
+ }
+
+ // Return a function that returns null when not inside a PortalProvider
+ return () => null;
+};
diff --git a/packages/shared/src/react/__tests__/PortalProvider.test.tsx b/packages/shared/src/react/__tests__/PortalProvider.test.tsx
new file mode 100644
index 00000000000..768959aa1ce
--- /dev/null
+++ b/packages/shared/src/react/__tests__/PortalProvider.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, it } from 'vitest';
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+
+import { UNSAFE_PortalProvider, usePortalRoot } from '../PortalProvider';
+
+describe('UNSAFE_PortalProvider', () => {
+ it('provides getContainer to children via context', () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+
+ const TestComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot === getContainer ? 'found' : 'not-found'}
;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('test').textContent).toBe('found');
+ });
+
+ it('only affects components within the provider', () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+
+ const InsideComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot() === container ? 'container' : 'null'}
;
+ };
+
+ const OutsideComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot() === null ? 'null' : 'container'}
;
+ };
+
+ render(
+ <>
+
+
+
+
+ >,
+ );
+
+ expect(screen.getByTestId('inside').textContent).toBe('container');
+ expect(screen.getByTestId('outside').textContent).toBe('null');
+ });
+});
+
+describe('usePortalRoot', () => {
+ it('returns getContainer from context when inside PortalProvider', () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+
+ const TestComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot() === container ? 'found' : 'not-found'}
;
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('test').textContent).toBe('found');
+ });
+
+ it('returns a function that returns null when outside PortalProvider', () => {
+ const TestComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot() === null ? 'null' : 'not-null'}
;
+ };
+
+ render();
+
+ expect(screen.getByTestId('test').textContent).toBe('null');
+ });
+
+ it('supports nested providers with innermost taking precedence', () => {
+ const outerContainer = document.createElement('div');
+ const innerContainer = document.createElement('div');
+
+ const TestComponent = () => {
+ const portalRoot = usePortalRoot();
+ return {portalRoot() === innerContainer ? 'inner' : 'outer'}
;
+ };
+
+ render(
+ outerContainer}>
+ innerContainer}>
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('test').textContent).toBe('inner');
+ });
+});
diff --git a/packages/shared/src/react/index.ts b/packages/shared/src/react/index.ts
index b05c94db6bd..b865b1602d4 100644
--- a/packages/shared/src/react/index.ts
+++ b/packages/shared/src/react/index.ts
@@ -20,3 +20,5 @@ export {
} from './contexts';
export * from './billing/payment-element';
+
+export { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider';
diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts
index d83d5db8cfb..d98009b0089 100644
--- a/packages/shared/src/types/clerk.ts
+++ b/packages/shared/src/types/clerk.ts
@@ -1422,9 +1422,22 @@ export interface TransferableOption {
transferable?: boolean;
}
-export type SignInModalProps = WithoutRouting;
+export type SignInModalProps = WithoutRouting & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
export type __internal_UserVerificationProps = RoutingOptions & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
/**
* Non-awaitable callback for when verification is completed successfully
*/
@@ -1566,7 +1579,14 @@ export type SignUpProps = RoutingOptions & {
SignInForceRedirectUrl &
AfterSignOutUrl;
-export type SignUpModalProps = WithoutRouting;
+export type SignUpModalProps = WithoutRouting & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
export type UserProfileProps = RoutingOptions & {
/**
@@ -1608,7 +1628,14 @@ export type UserProfileProps = RoutingOptions & {
};
};
-export type UserProfileModalProps = WithoutRouting;
+export type UserProfileModalProps = WithoutRouting & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
export type OrganizationProfileProps = RoutingOptions & {
/**
@@ -1651,7 +1678,14 @@ export type OrganizationProfileProps = RoutingOptions & {
};
};
-export type OrganizationProfileModalProps = WithoutRouting;
+export type OrganizationProfileModalProps = WithoutRouting & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
export type CreateOrganizationProps = RoutingOptions & {
/**
@@ -1677,7 +1711,14 @@ export type CreateOrganizationProps = RoutingOptions & {
appearance?: ClerkAppearanceTheme;
};
-export type CreateOrganizationModalProps = WithoutRouting;
+export type CreateOrganizationModalProps = WithoutRouting & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
type UserProfileMode = 'modal' | 'navigation';
type UserButtonProfileMode =
@@ -1900,7 +1941,14 @@ export type WaitlistProps = {
signInUrl?: string;
};
-export type WaitlistModalProps = WaitlistProps;
+export type WaitlistModalProps = WaitlistProps & {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Radix Dialog, React Aria Components) instead of document.body.
+ */
+ getContainer?: () => HTMLElement | null;
+};
type PricingTableDefaultProps = {
/**
diff --git a/packages/tanstack-react-start/src/client/index.ts b/packages/tanstack-react-start/src/client/index.ts
index 09ce51e6a72..edd5e28a1b1 100644
--- a/packages/tanstack-react-start/src/client/index.ts
+++ b/packages/tanstack-react-start/src/client/index.ts
@@ -1,2 +1,3 @@
export * from './ClerkProvider';
export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents';
+export { UNSAFE_PortalProvider } from '@clerk/react';
diff --git a/packages/ui/src/Components.tsx b/packages/ui/src/Components.tsx
index 719187bfadd..73bcdaaa4fa 100644
--- a/packages/ui/src/Components.tsx
+++ b/packages/ui/src/Components.tsx
@@ -466,6 +466,7 @@ const Components = (props: ComponentsProps) => {
onClose={() => componentsControls.closeModal('signIn')}
onExternalNavigate={() => componentsControls.closeModal('signIn')}
startPath={buildVirtualRouterUrl({ base: '/sign-in', path: urlStateParam?.path })}
+ getContainer={signInModal?.getContainer}
componentName={'SignInModal'}
>
@@ -483,6 +484,7 @@ const Components = (props: ComponentsProps) => {
onClose={() => componentsControls.closeModal('signUp')}
onExternalNavigate={() => componentsControls.closeModal('signUp')}
startPath={buildVirtualRouterUrl({ base: '/sign-up', path: urlStateParam?.path })}
+ getContainer={signUpModal?.getContainer}
componentName={'SignUpModal'}
>
@@ -503,6 +505,7 @@ const Components = (props: ComponentsProps) => {
base: '/user',
path: userProfileModal?.__experimental_startPath || urlStateParam?.path,
})}
+ getContainer={userProfileModal?.getContainer}
componentName={'UserProfileModal'}
modalContainerSx={{ alignItems: 'center' }}
modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })}
@@ -520,6 +523,7 @@ const Components = (props: ComponentsProps) => {
onClose={() => componentsControls.closeModal('userVerification')}
onExternalNavigate={() => componentsControls.closeModal('userVerification')}
startPath={buildVirtualRouterUrl({ base: '/user-verification', path: urlStateParam?.path })}
+ getContainer={userVerificationModal?.getContainer}
componentName={'UserVerificationModal'}
modalContainerSx={{ alignItems: 'center' }}
>
@@ -539,6 +543,7 @@ const Components = (props: ComponentsProps) => {
base: '/organizationProfile',
path: organizationProfileModal?.__experimental_startPath || urlStateParam?.path,
})}
+ getContainer={organizationProfileModal?.getContainer}
componentName={'OrganizationProfileModal'}
modalContainerSx={{ alignItems: 'center' }}
modalContentSx={t => ({ height: `min(${t.sizes.$176}, calc(100% - ${t.sizes.$12}))`, margin: 0 })}
@@ -556,6 +561,7 @@ const Components = (props: ComponentsProps) => {
onClose={() => componentsControls.closeModal('createOrganization')}
onExternalNavigate={() => componentsControls.closeModal('createOrganization')}
startPath={buildVirtualRouterUrl({ base: '/createOrganization', path: urlStateParam?.path })}
+ getContainer={createOrganizationModal?.getContainer}
componentName={'CreateOrganizationModal'}
modalContainerSx={{ alignItems: 'center' }}
modalContentSx={t => ({ height: `min(${t.sizes.$120}, calc(100% - ${t.sizes.$12}))`, margin: 0 })}
@@ -573,6 +579,7 @@ const Components = (props: ComponentsProps) => {
onClose={() => componentsControls.closeModal('waitlist')}
onExternalNavigate={() => componentsControls.closeModal('waitlist')}
startPath={buildVirtualRouterUrl({ base: '/waitlist', path: urlStateParam?.path })}
+ getContainer={waitlistModal?.getContainer}
componentName={'WaitlistModal'}
>
diff --git a/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx
new file mode 100644
index 00000000000..9a73acdcda4
--- /dev/null
+++ b/packages/ui/src/components/APIKeys/__tests__/APIKeyModal.test.tsx
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest';
+import React from 'react';
+import { render } from '@testing-library/react';
+
+import { UNSAFE_PortalProvider } from '@clerk/react';
+
+import { APIKeyModal } from '../APIKeyModal';
+
+describe('APIKeyModal modalRoot behavior', () => {
+ it('renders modal inside modalRoot when provided', () => {
+ const modalRoot = React.createRef();
+ const container = document.createElement('div');
+ modalRoot.current = container;
+ document.body.appendChild(container);
+
+ const { container: testContainer } = render(
+ {}}
+ handleClose={() => {}}
+ canCloseModal={true}
+ >
+ Test Content
+ ,
+ );
+
+ // The modal should render inside the modalRoot container, not document.body
+ // We can verify this by checking that the modal content is within the container
+ expect(container.querySelector('[data-testid="modal-content"]')).toBeInTheDocument();
+
+ document.body.removeChild(container);
+ });
+
+ it('applies scoped portal container styles when modalRoot provided', () => {
+ const modalRoot = React.createRef();
+ const container = document.createElement('div');
+ modalRoot.current = container;
+ document.body.appendChild(container);
+
+ const { container: testContainer } = render(
+ {}}
+ handleClose={() => {}}
+ canCloseModal={true}
+ >
+ Test
+ ,
+ );
+
+ // The modal should have scoped styles (position: absolute) when modalRoot is provided
+ const modalElement = container.querySelector('[data-clerk-element="modalBackdrop"]');
+ expect(modalElement).toBeTruthy();
+
+ document.body.removeChild(container);
+ });
+
+ it('modalRoot takes precedence over PortalProvider context', () => {
+ const modalRoot = React.createRef();
+ const container1 = document.createElement('div');
+ const container2 = document.createElement('div');
+ modalRoot.current = container1;
+ document.body.appendChild(container1);
+ document.body.appendChild(container2);
+
+ const getContainer = () => container2;
+
+ const { container: testContainer } = render(
+
+ {}}
+ handleClose={() => {}}
+ canCloseModal={true}
+ >
+ Test Content
+
+ ,
+ );
+
+ // The modal should render in container1 (modalRoot), not container2 (PortalProvider)
+ expect(container1.querySelector('[data-testid="modal-content"]')).toBeInTheDocument();
+ expect(container2.querySelector('[data-testid="modal-content"]')).not.toBeInTheDocument();
+
+ document.body.removeChild(container1);
+ document.body.removeChild(container2);
+ });
+});
diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
index aec8b0f21de..5e58e0e87d9 100644
--- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
+++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
@@ -1,4 +1,4 @@
-import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/shared/react';
+import { useClerk, useOrganization, useOrganizationList, usePortalRoot, useUser } from '@clerk/shared/react';
import type { OrganizationResource } from '@clerk/shared/types';
import React from 'react';
@@ -25,6 +25,7 @@ export const OrganizationSwitcherPopover = React.forwardRef {
@@ -88,6 +89,7 @@ export const OrganizationSwitcherPopover = React.forwardRef {
);
});
});
+
+ describe('OrganizationSwitcher with PortalProvider', () => {
+ it('passes getContainer to openOrganizationProfile', async () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { getByRole, userEvent } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await userEvent.click(getByRole('button'));
+ const manageButton = await waitFor(() => getByRole('menuitem', { name: /manage organization/i }));
+ await userEvent.click(manageButton);
+
+ expect(fixtures.clerk.openOrganizationProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer }));
+ });
+
+ it('passes getContainer to openCreateOrganization', async () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withOrganizations();
+ f.withUser({ email_addresses: ['test@clerk.com'] });
+ });
+
+ const { getByRole, userEvent } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await userEvent.click(getByRole('button', { name: 'Open organization switcher' }));
+ const createButton = await waitFor(() => getByRole('menuitem', { name: 'Create organization' }));
+ await userEvent.click(createButton);
+
+ expect(fixtures.clerk.openCreateOrganization).toHaveBeenCalledWith(expect.objectContaining({ getContainer }));
+ });
+ });
});
diff --git a/packages/ui/src/components/PricingTable/PricingTable.tsx b/packages/ui/src/components/PricingTable/PricingTable.tsx
index be84d597b71..9ced0c884ff 100644
--- a/packages/ui/src/components/PricingTable/PricingTable.tsx
+++ b/packages/ui/src/components/PricingTable/PricingTable.tsx
@@ -1,4 +1,4 @@
-import { useClerk } from '@clerk/shared/react';
+import { useClerk, usePortalRoot } from '@clerk/shared/react';
import type { BillingPlanResource, BillingSubscriptionPlanPeriod, PricingTableProps } from '@clerk/shared/types';
import { useEffect, useMemo, useState } from 'react';
@@ -10,6 +10,7 @@ import { PricingTableMatrix } from './PricingTableMatrix';
const PricingTableRoot = (props: PricingTableProps) => {
const clerk = useClerk();
+ const getContainer = usePortalRoot();
const { mode = 'mounted', signInMode = 'redirect' } = usePricingTableContext();
const isCompact = mode === 'modal';
const { data: subscription, subscriptionItems } = useSubscription();
@@ -52,7 +53,7 @@ const PricingTableRoot = (props: PricingTableProps) => {
const selectPlan = (plan: BillingPlanResource, event?: React.MouseEvent) => {
if (!clerk.isSignedIn) {
if (signInMode === 'modal') {
- return clerk.openSignIn();
+ return clerk.openSignIn({ getContainer });
}
return clerk.redirectToSignIn();
}
diff --git a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx
index cc5c292751f..64ed3ac95e3 100644
--- a/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx
+++ b/packages/ui/src/components/UserButton/__tests__/UserButton.test.tsx
@@ -1,4 +1,7 @@
import { describe, expect, it } from 'vitest';
+import React from 'react';
+
+import { UNSAFE_PortalProvider } from '@clerk/react';
import { bindCreateFixtures } from '@/test/create-fixtures';
import { render, waitFor } from '@/test/utils';
@@ -84,6 +87,33 @@ describe('UserButton', () => {
it.todo('navigates to sign in url when "Add account" is clicked');
+ describe('UserButton with PortalProvider', () => {
+ it('passes getContainer to openUserProfile when wrapped in PortalProvider', async () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+ const { wrapper, fixtures } = await createFixtures(f => {
+ f.withUser({
+ first_name: 'First',
+ last_name: 'Last',
+ username: 'username1',
+ email_addresses: ['test@clerk.com'],
+ });
+ });
+
+ const { getByText, getByRole, userEvent } = render(
+
+
+ ,
+ { wrapper },
+ );
+
+ await userEvent.click(getByRole('button', { name: 'Open user menu' }));
+ await userEvent.click(getByText('Manage account'));
+
+ expect(fixtures.clerk.openUserProfile).toHaveBeenCalledWith(expect.objectContaining({ getContainer }));
+ });
+ });
+
describe('Multi Session Popover', () => {
const initConfig = createFixtures.config(f => {
f.withMultiSessionMode();
diff --git a/packages/ui/src/components/UserButton/useMultisessionActions.tsx b/packages/ui/src/components/UserButton/useMultisessionActions.tsx
index 709fd29ecce..8b045f99f7a 100644
--- a/packages/ui/src/components/UserButton/useMultisessionActions.tsx
+++ b/packages/ui/src/components/UserButton/useMultisessionActions.tsx
@@ -1,6 +1,6 @@
import { navigateIfTaskExists } from '@clerk/shared/internal/clerk-js/sessionTasks';
import { windowNavigate } from '@clerk/shared/internal/clerk-js/windowNavigate';
-import { useClerk } from '@clerk/shared/react';
+import { useClerk, usePortalRoot } from '@clerk/shared/react';
import type { SignedInSessionResource, UserButtonProps, UserResource } from '@clerk/shared/types';
import { useEnvironment } from '@/ui/contexts';
@@ -27,6 +27,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
const { signedInSessions, otherSessions } = useMultipleSessions({ user: opts.user });
const { navigate } = useRouter();
const { displayConfig } = useEnvironment();
+ const getContainer = usePortalRoot();
const handleSignOutSessionClicked = (session: SignedInSessionResource) => () => {
if (otherSessions.length === 0) {
@@ -46,7 +47,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
})();
});
}
- openUserProfile(opts.userProfileProps);
+ openUserProfile({ getContainer, ...opts.userProfileProps });
return opts.actionCompleteCallback?.();
};
@@ -60,6 +61,7 @@ export const useMultisessionActions = (opts: UseMultisessionActionsParams) => {
});
}
openUserProfile({
+ getContainer,
...opts.userProfileProps,
...(__experimental_startPath && { __experimental_startPath }),
});
diff --git a/packages/ui/src/elements/Drawer.tsx b/packages/ui/src/elements/Drawer.tsx
index 29fb30caa35..317ce0ca0f3 100644
--- a/packages/ui/src/elements/Drawer.tsx
+++ b/packages/ui/src/elements/Drawer.tsx
@@ -1,4 +1,4 @@
-import { useSafeLayoutEffect } from '@clerk/shared/react/index';
+import { usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react/index';
import type { UseDismissProps, UseFloatingOptions, UseRoleProps } from '@floating-ui/react';
import {
FloatingFocusManager,
@@ -88,6 +88,8 @@ function Root({
dismissProps,
}: RootProps) {
const direction = useDirection();
+ const portalRoot = usePortalRoot();
+ const effectivePortalRoot = portalProps?.root ?? portalRoot?.() ?? undefined;
const { refs, context } = useFloating({
open,
@@ -110,14 +112,19 @@ function Root({
isOpen: open,
setIsOpen: onOpenChange,
strategy,
- portalProps: portalProps || {},
+ portalProps: { ...portalProps, root: effectivePortalRoot },
refs,
context,
getFloatingProps,
direction,
}}
>
- {children}
+
+ {children}
+
);
}
diff --git a/packages/ui/src/elements/Menu.tsx b/packages/ui/src/elements/Menu.tsx
index 0788ffdda6a..0579758d082 100644
--- a/packages/ui/src/elements/Menu.tsx
+++ b/packages/ui/src/elements/Menu.tsx
@@ -199,6 +199,7 @@ export const MenuItem = (props: MenuItemProps) => {
justifyContent: 'start',
borderRadius: theme.radii.$sm,
padding: `${theme.space.$1} ${theme.space.$3}`,
+ whiteSpace: 'nowrap',
}),
sx,
]}
diff --git a/packages/ui/src/elements/Modal.tsx b/packages/ui/src/elements/Modal.tsx
index 46934f2a5d8..588db619795 100644
--- a/packages/ui/src/elements/Modal.tsx
+++ b/packages/ui/src/elements/Modal.tsx
@@ -1,4 +1,4 @@
-import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react';
+import { createContextAndHook, usePortalRoot, useSafeLayoutEffect } from '@clerk/shared/react';
import React, { useRef } from 'react';
import { descriptors, Flex } from '../customizables';
@@ -27,6 +27,7 @@ export const Modal = withFloatingTree((props: ModalProps) => {
const { disableScrollLock, enableScrollLock } = useScrollLock();
const { handleClose, handleOpen, contentSx, containerSx, canCloseModal, id, style, portalRoot, initialFocusRef } =
props;
+ const portalRootFromContext = usePortalRoot();
const overlayRef = useRef(null);
const { floating, isOpen, context, nodeId, toggle } = usePopover({
defaultOpen: true,
@@ -52,13 +53,15 @@ export const Modal = withFloatingTree((props: ModalProps) => {
};
}, []);
+ const effectivePortalRoot = portalRoot ?? portalRootFromContext?.() ?? undefined;
+
return (
diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx
index 826adc7860f..770ca00a9dc 100644
--- a/packages/ui/src/elements/Popover.tsx
+++ b/packages/ui/src/elements/Popover.tsx
@@ -1,3 +1,4 @@
+import { usePortalRoot } from '@clerk/shared/react';
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
import type { PropsWithChildren } from 'react';
@@ -35,10 +36,13 @@ export const Popover = (props: PopoverProps) => {
children,
} = props;
+ const portalRoot = usePortalRoot();
+ const effectiveRoot = root ?? portalRoot?.() ?? undefined;
+
if (portal) {
return (
-
+
{isOpen && (
(function TooltipContent({ style, text, sx, ...props }, propRef) {
const context = useTooltipContext();
const ref = useMergeRefs([context.refs.setFloating, propRef]);
+ const portalRoot = usePortalRoot();
+ const effectiveRoot = portalRoot?.() ?? undefined;
if (!context.isMounted) {
return null;
}
return (
-
+
{
appearanceKey={props.appearanceKey}
appearance={props.componentAppearance}
>
-
- >
- }
- props={props.componentProps}
- componentName={props.componentName}
- />
+
+
+ >
+ }
+ props={props.componentProps}
+ componentName={props.componentName}
+ />
+
);
};
@@ -113,6 +116,7 @@ type LazyModalRendererProps = React.PropsWithChildren<
canCloseModal?: boolean;
modalId?: string;
modalStyle?: React.CSSProperties;
+ getContainer: () => HTMLElement | null;
} & AppearanceProviderProps
>;
@@ -126,27 +130,29 @@ export const LazyModalRenderer = (props: LazyModalRendererProps) => {
>
-
- {props.startPath ? (
-
-
- {props.children}
-
-
- ) : (
- props.children
- )}
-
+
+
+ {props.startPath ? (
+
+
+ {props.children}
+
+
+ ) : (
+ props.children
+ )}
+
+
diff --git a/packages/vue/src/components/ClerkHostRenderer.ts b/packages/vue/src/components/ClerkHostRenderer.ts
index e3e9f692837..a24afaf52cf 100644
--- a/packages/vue/src/components/ClerkHostRenderer.ts
+++ b/packages/vue/src/components/ClerkHostRenderer.ts
@@ -1,6 +1,7 @@
import type { PropType } from 'vue';
import { defineComponent, h, onUnmounted, ref, watch, watchEffect } from 'vue';
+import { usePortalRoot } from '../composables/usePortalRoot';
import type { CustomPortalsRendererProps } from '../types';
import { ClerkLoaded } from './controlComponents';
@@ -44,6 +45,7 @@ export const ClerkHostRenderer = defineComponent({
},
setup(props) {
const portalRef = ref(null);
+ const getContainer = usePortalRoot();
let isPortalMounted = false;
watchEffect(() => {
@@ -52,11 +54,16 @@ export const ClerkHostRenderer = defineComponent({
return;
}
+ const propsWithContainer = {
+ ...props.props,
+ getContainer,
+ };
+
if (props.mount) {
- props.mount(portalRef.value, props.props);
+ props.mount(portalRef.value, propsWithContainer);
}
if (props.open) {
- props.open(props.props);
+ props.open(propsWithContainer);
}
isPortalMounted = true;
});
@@ -65,7 +72,11 @@ export const ClerkHostRenderer = defineComponent({
() => props.props,
newProps => {
if (isPortalMounted && props.updateProps && portalRef.value) {
- props.updateProps({ node: portalRef.value, props: newProps });
+ const propsWithContainer = {
+ ...newProps,
+ getContainer,
+ };
+ props.updateProps({ node: portalRef.value, props: propsWithContainer });
}
},
{ deep: true },
diff --git a/packages/vue/src/components/PortalProvider.ts b/packages/vue/src/components/PortalProvider.ts
new file mode 100644
index 00000000000..ec49c81c856
--- /dev/null
+++ b/packages/vue/src/components/PortalProvider.ts
@@ -0,0 +1,52 @@
+import { defineComponent, type PropType, provide } from 'vue';
+
+import { PortalInjectionKey } from '../keys';
+
+/**
+ * UNSAFE_PortalProvider allows you to specify a custom container for Clerk floating UI elements
+ * (popovers, modals, tooltips, etc.) that use portals.
+ *
+ * Only components within this provider will be affected. Components outside the provider
+ * will continue to use the default document.body for portals.
+ *
+ * This is particularly useful when using Clerk components inside external UI libraries
+ * like Reka UI Dialog, where portaled elements need to render within the dialog's
+ * container to remain interactable.
+ *
+ * @example
+ * ```vue
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * ```
+ */
+export const UNSAFE_PortalProvider = defineComponent({
+ name: 'UNSAFE_PortalProvider',
+ props: {
+ /**
+ * Function that returns the container element where portals should be rendered.
+ * This allows Clerk components to render inside external dialogs/popovers
+ * (e.g., Reka UI Dialog) instead of document.body.
+ */
+ getContainer: {
+ type: Function as PropType<() => HTMLElement | null>,
+ required: true,
+ },
+ },
+ setup(props, { slots }) {
+ provide(PortalInjectionKey, { getContainer: props.getContainer });
+ return () => slots.default?.();
+ },
+});
diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts
index 65c8398137f..dd19fafc2bd 100644
--- a/packages/vue/src/components/index.ts
+++ b/packages/vue/src/components/index.ts
@@ -30,3 +30,4 @@ export { default as SignInButton } from './SignInButton.vue';
export { default as SignUpButton } from './SignUpButton.vue';
export { default as SignOutButton } from './SignOutButton.vue';
export { default as SignInWithMetamaskButton } from './SignInWithMetamaskButton.vue';
+export { UNSAFE_PortalProvider } from './PortalProvider';
diff --git a/packages/vue/src/composables/__tests__/usePortalRoot.test.ts b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts
new file mode 100644
index 00000000000..47f7d601a2e
--- /dev/null
+++ b/packages/vue/src/composables/__tests__/usePortalRoot.test.ts
@@ -0,0 +1,100 @@
+import { render } from '@testing-library/vue';
+import { describe, expect, it } from 'vitest';
+import { defineComponent, h } from 'vue';
+
+import { UNSAFE_PortalProvider } from '../../components/PortalProvider';
+import { usePortalRoot } from '../usePortalRoot';
+
+describe('usePortalRoot', () => {
+ it('returns getContainer from context when inside PortalProvider', () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+
+ const TestComponent = defineComponent({
+ setup() {
+ const portalRoot = usePortalRoot();
+ return () => h('div', { 'data-testid': 'test' }, portalRoot() === container ? 'found' : 'not-found');
+ },
+ });
+
+ const { getByTestId } = render(h(UNSAFE_PortalProvider, { getContainer }, () => h(TestComponent)));
+
+ expect(getByTestId('test').textContent).toBe('found');
+ });
+
+ it('returns a function that returns null when outside PortalProvider', () => {
+ const TestComponent = defineComponent({
+ setup() {
+ const portalRoot = usePortalRoot();
+ return () => h('div', { 'data-testid': 'test' }, portalRoot() === null ? 'null' : 'not-null');
+ },
+ });
+
+ const { getByTestId } = render(TestComponent);
+
+ expect(getByTestId('test').textContent).toBe('null');
+ });
+
+ it('only affects components within the provider', () => {
+ const container = document.createElement('div');
+ const getContainer = () => container;
+
+ const InsideComponent = defineComponent({
+ setup() {
+ const portalRoot = usePortalRoot();
+ return () => h('div', { 'data-testid': 'inside' }, portalRoot() === container ? 'container' : 'null');
+ },
+ });
+
+ const OutsideComponent = defineComponent({
+ setup() {
+ const portalRoot = usePortalRoot();
+ return () => h('div', { 'data-testid': 'outside' }, portalRoot() === null ? 'null' : 'container');
+ },
+ });
+
+ const { getByTestId } = render({
+ components: { InsideComponent, OutsideComponent, UNSAFE_PortalProvider },
+ template: `
+
+
+
+
+ `,
+ setup() {
+ return { getContainer };
+ },
+ });
+
+ expect(getByTestId('inside').textContent).toBe('container');
+ expect(getByTestId('outside').textContent).toBe('null');
+ });
+
+ it('supports nested providers with innermost taking precedence', () => {
+ const outerContainer = document.createElement('div');
+ const innerContainer = document.createElement('div');
+
+ const TestComponent = defineComponent({
+ setup() {
+ const portalRoot = usePortalRoot();
+ return () => h('div', { 'data-testid': 'test' }, portalRoot() === innerContainer ? 'inner' : 'outer');
+ },
+ });
+
+ const { getByTestId } = render({
+ components: { TestComponent, UNSAFE_PortalProvider },
+ template: `
+
+
+
+
+
+ `,
+ setup() {
+ return { outerContainer, innerContainer };
+ },
+ });
+
+ expect(getByTestId('test').textContent).toBe('inner');
+ });
+});
diff --git a/packages/vue/src/composables/index.ts b/packages/vue/src/composables/index.ts
index 9ca30fcc06c..7adac21c765 100644
--- a/packages/vue/src/composables/index.ts
+++ b/packages/vue/src/composables/index.ts
@@ -13,3 +13,5 @@ export { useSignUp } from './useSignUp';
export { useSessionList } from './useSessionList';
export { useOrganization } from './useOrganization';
+
+export { usePortalRoot } from './usePortalRoot';
diff --git a/packages/vue/src/composables/usePortalRoot.ts b/packages/vue/src/composables/usePortalRoot.ts
new file mode 100644
index 00000000000..03adf0cf453
--- /dev/null
+++ b/packages/vue/src/composables/usePortalRoot.ts
@@ -0,0 +1,19 @@
+import { inject } from 'vue';
+
+import { PortalInjectionKey } from '../keys';
+
+/**
+ * Composable to get the current portal root container.
+ * Returns the getContainer function from context if inside a PortalProvider,
+ * otherwise returns a function that returns null (default behavior).
+ */
+export const usePortalRoot = (): (() => HTMLElement | null) => {
+ const context = inject(PortalInjectionKey, null);
+
+ if (context && context.getContainer) {
+ return context.getContainer;
+ }
+
+ // Return a function that returns null when not inside a PortalProvider
+ return () => null;
+};
diff --git a/packages/vue/src/keys.ts b/packages/vue/src/keys.ts
index 7a5ca1f5d38..e012c639c3b 100644
--- a/packages/vue/src/keys.ts
+++ b/packages/vue/src/keys.ts
@@ -19,3 +19,7 @@ export const UserProfileInjectionKey = Symbol('UserProfile') as InjectionKey<{
export const OrganizationProfileInjectionKey = Symbol('OrganizationProfile') as InjectionKey<{
addCustomPage(params: AddCustomPagesParams): void;
}>;
+
+export const PortalInjectionKey = Symbol('Portal') as InjectionKey<{
+ getContainer: () => HTMLElement | null;
+}>;