Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
71ff27a
init
alexcarpenter Nov 25, 2025
922010b
Create wet-phones-camp.md
alexcarpenter Nov 25, 2025
3d5f3bd
Apply suggestion from @alexcarpenter
alexcarpenter Nov 25, 2025
e9b66d5
wip
alexcarpenter Nov 25, 2025
af3e86b
Merge branch 'alexcarpenter/portal-provider-3' of github.com:clerk/ja…
alexcarpenter Nov 25, 2025
2423766
wip
alexcarpenter Dec 1, 2025
66ae887
Merge branch 'main' into alexcarpenter/portal-provider-3
alexcarpenter Dec 15, 2025
44854e1
expand portalprovider usage
alexcarpenter Dec 15, 2025
fe0c34a
fix export
alexcarpenter Dec 15, 2025
f3c69a3
Merge branch 'main' into alexcarpenter/portal-provider-3
alexcarpenter Dec 15, 2025
c90b168
rename to UNSAFE_PortalProvider
alexcarpenter Dec 15, 2025
998c30a
fix menu item wrapping
alexcarpenter Dec 15, 2025
7d4e63c
remove PortalRootManager usage
alexcarpenter Dec 15, 2025
fa6ef65
feat(vue): UNSAFE_PortalProvider implementation (#7491)
wobsoriano Dec 17, 2025
fd628a2
add changeset
alexcarpenter Jan 8, 2026
5532a47
Delete .changeset/wet-phones-camp.md
alexcarpenter Jan 8, 2026
1b9d5a0
Merge branch 'main' into alexcarpenter/portal-provider-3
alexcarpenter Jan 8, 2026
13f622f
Merge branch 'alexcarpenter/portal-provider-3' of github.com:clerk/ja…
alexcarpenter Jan 8, 2026
7bbc154
Merge branch 'main' into alexcarpenter/portal-provider-3
alexcarpenter Jan 8, 2026
d1c12d1
remove duplicates
alexcarpenter Jan 8, 2026
ee2bc55
Merge branch 'alexcarpenter/portal-provider-3' of github.com:clerk/ja…
alexcarpenter Jan 8, 2026
632874a
chore(ui): Modify bundle limits
dstaley Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/wicked-friends-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@clerk/tanstack-react-start': minor
'@clerk/react-router': minor
'@clerk/nextjs': minor
'@clerk/shared': minor
'@clerk/astro': minor
'@clerk/react': minor
'@clerk/expo': minor
'@clerk/nuxt': minor
'@clerk/vue': minor
'@clerk/ui': minor
---

Introduce `<UNSAFE_PortalProvider>` component which allows you to specify a custom container for Clerk floating UI elements (popovers, modals, tooltips, etc.) that use portals. Only Clerk components within the 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](https://www.radix-ui.com/primitives/docs/components/dialog) or [React Aria Components](https://react-spectrum.adobe.com/react-aria/components.html), where portaled elements need to render within the dialog's container to remain interact-able.

```tsx
'use client';

import { useRef } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { UNSAFE_PortalProvider, UserButton } from '@clerk/nextjs';

export function UserDialog() {
const containerRef = useRef<HTMLDivElement>(null);

return (
<Dialog.Root>
<Dialog.Trigger>Open Dialog</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content ref={containerRef}>
<UNSAFE_PortalProvider getContainer={() => containerRef.current}>
<UserButton />
</UNSAFE_PortalProvider>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
```
1 change: 1 addition & 0 deletions packages/astro/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/expo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,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 });
Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/client-boundary/controlComponents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export {
RedirectToSignUp,
RedirectToTasks,
RedirectToUserProfile,
UNSAFE_PortalProvider,
Show,
} from '@clerk/react';

Expand Down
1 change: 1 addition & 0 deletions packages/nextjs/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
ClerkFailed,
ClerkLoaded,
ClerkLoading,
UNSAFE_PortalProvider,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
RedirectToSignIn,
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/src/runtime/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ export {
SignOutButton,
SignInWithMetamaskButton,
PricingTable,
UNSAFE_PortalProvider,
} from '@clerk/vue';
1 change: 1 addition & 0 deletions packages/react-router/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions packages/react/src/components/withClerk.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { usePortalRoot } from '@clerk/shared/react';
import type { LoadedClerk, Without } from '@clerk/shared/types';
import React from 'react';

Expand All @@ -19,13 +20,15 @@ export const withClerk = <P extends { clerk: LoadedClerk; component?: string }>(
useAssertWrappedByClerkProvider(displayName || 'withClerk');

const clerk = useIsomorphicClerkContext();
const getContainer = usePortalRoot();

if (!clerk.loaded && !options?.renderWhileLoading) {
return null;
}

return (
<Component
getContainer={getContainer}
{...(props as P)}
component={displayName}
clerk={clerk}
Expand Down
1 change: 1 addition & 0 deletions packages/react/src/contexts/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { ClerkProvider } from './ClerkProvider';
export { UNSAFE_PortalProvider } from '@clerk/shared/react';
65 changes: 65 additions & 0 deletions packages/shared/src/react/PortalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
'use client';

import React from 'react';

import { createContextAndHook } from './hooks/createContextAndHook';

type PortalProviderProps = React.PropsWithChildren<{
/**
* 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;
}>;

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 (
* <RadixDialog ref={containerRef}>
* <UNSAFE_PortalProvider getContainer={() => containerRef.current}>
* <UserButton />
* </UNSAFE_PortalProvider>
* </RadixDialog>
* );
* }
* ```
*/
export const UNSAFE_PortalProvider = ({ children, getContainer }: PortalProviderProps) => {
const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]);

return <PortalContext.Provider value={contextValue}>{children}</PortalContext.Provider>;
};

/**
* 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;
};
103 changes: 103 additions & 0 deletions packages/shared/src/react/__tests__/PortalProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest';

Check failure on line 1 in packages/shared/src/react/__tests__/PortalProvider.test.tsx

View workflow job for this annotation

GitHub Actions / Static analysis

Run autofix to sort these imports!
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 <div data-testid='test'>{portalRoot === getContainer ? 'found' : 'not-found'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={getContainer}>
<TestComponent />
</UNSAFE_PortalProvider>,
);

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 <div data-testid='inside'>{portalRoot() === container ? 'container' : 'null'}</div>;
};

const OutsideComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='outside'>{portalRoot() === null ? 'null' : 'container'}</div>;
};

render(
<>
<OutsideComponent />
<UNSAFE_PortalProvider getContainer={getContainer}>
<InsideComponent />
</UNSAFE_PortalProvider>
</>,
);

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 <div data-testid='test'>{portalRoot() === container ? 'found' : 'not-found'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={getContainer}>
<TestComponent />
</UNSAFE_PortalProvider>,
);

expect(screen.getByTestId('test').textContent).toBe('found');
});

it('returns a function that returns null when outside PortalProvider', () => {
const TestComponent = () => {
const portalRoot = usePortalRoot();
return <div data-testid='test'>{portalRoot() === null ? 'null' : 'not-null'}</div>;
};

render(<TestComponent />);

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 <div data-testid='test'>{portalRoot() === innerContainer ? 'inner' : 'outer'}</div>;
};

render(
<UNSAFE_PortalProvider getContainer={() => outerContainer}>
<UNSAFE_PortalProvider getContainer={() => innerContainer}>
<TestComponent />
</UNSAFE_PortalProvider>
</UNSAFE_PortalProvider>,
);

expect(screen.getByTestId('test').textContent).toBe('inner');
});
});
2 changes: 2 additions & 0 deletions packages/shared/src/react/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ export {
} from './contexts';

export * from './billing/payment-element';

export { UNSAFE_PortalProvider, usePortalRoot } from './PortalProvider';
60 changes: 54 additions & 6 deletions packages/shared/src/types/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1440,9 +1440,22 @@ export interface TransferableOption {
transferable?: boolean;
}

export type SignInModalProps = WithoutRouting<SignInProps>;
export type SignInModalProps = WithoutRouting<SignInProps> & {
/**
* 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
*/
Expand Down Expand Up @@ -1584,7 +1597,14 @@ export type SignUpProps = RoutingOptions & {
SignInForceRedirectUrl &
AfterSignOutUrl;

export type SignUpModalProps = WithoutRouting<SignUpProps>;
export type SignUpModalProps = WithoutRouting<SignUpProps> & {
/**
* 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 & {
/**
Expand Down Expand Up @@ -1626,7 +1646,14 @@ export type UserProfileProps = RoutingOptions & {
};
};

export type UserProfileModalProps = WithoutRouting<UserProfileProps>;
export type UserProfileModalProps = WithoutRouting<UserProfileProps> & {
/**
* 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 & {
/**
Expand Down Expand Up @@ -1669,7 +1696,14 @@ export type OrganizationProfileProps = RoutingOptions & {
};
};

export type OrganizationProfileModalProps = WithoutRouting<OrganizationProfileProps>;
export type OrganizationProfileModalProps = WithoutRouting<OrganizationProfileProps> & {
/**
* 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 & {
/**
Expand All @@ -1695,7 +1729,14 @@ export type CreateOrganizationProps = RoutingOptions & {
appearance?: ClerkAppearanceTheme;
};

export type CreateOrganizationModalProps = WithoutRouting<CreateOrganizationProps>;
export type CreateOrganizationModalProps = WithoutRouting<CreateOrganizationProps> & {
/**
* 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 =
Expand Down Expand Up @@ -1918,7 +1959,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 = {
/**
Expand Down
1 change: 1 addition & 0 deletions packages/tanstack-react-start/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './ClerkProvider';
export { SignIn, SignUp, OrganizationProfile, OrganizationList, UserProfile } from './uiComponents';
export { UNSAFE_PortalProvider } from '@clerk/react';
Loading
Loading