Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
27 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
f3045f7
Update exports.test.ts.snap
alexcarpenter Jan 9, 2026
f80e145
Update exports.test.ts.snap
alexcarpenter Jan 9, 2026
a3a64e9
sort
alexcarpenter Jan 9, 2026
9814d4b
update expo export
alexcarpenter Jan 16, 2026
01126b7
Merge branch 'main' into alexcarpenter/portal-provider-3
alexcarpenter Jan 16, 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
2 changes: 2 additions & 0 deletions .changeset/wet-phones-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
7 changes: 5 additions & 2 deletions packages/clerk-js/src/ui/elements/Modal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContextAndHook, useSafeLayoutEffect } from '@clerk/shared/react';
import { createContextAndHook, useSafeLayoutEffect, usePortalRoot } from '@clerk/shared/react';
import React, { useRef } from 'react';

import { descriptors, Flex } from '../customizables';
Expand Down Expand Up @@ -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<HTMLDivElement>(null);
const { floating, isOpen, context, nodeId, toggle } = usePopover({
defaultOpen: true,
Expand All @@ -52,13 +53,15 @@ export const Modal = withFloatingTree((props: ModalProps) => {
};
}, []);

const effectivePortalRoot = portalRoot ?? portalRootFromContext ?? undefined;

return (
<Popover
nodeId={nodeId}
context={context}
isOpen={isOpen}
outsideElementsInert
root={portalRoot}
root={effectivePortalRoot}
initialFocus={initialFocusRef}
>
<ModalContext.Provider value={modalCtx}>
Expand Down
6 changes: 5 additions & 1 deletion packages/clerk-js/src/ui/elements/Popover.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
import { usePortalRoot } from '@clerk/shared/react';
import type { PropsWithChildren } from 'react';
import React from 'react';

Expand Down Expand Up @@ -35,10 +36,13 @@ export const Popover = (props: PopoverProps) => {
children,
} = props;

const portalRoot = usePortalRoot();
const effectiveRoot = root ?? portalRoot ?? undefined;

if (portal) {
return (
<FloatingNode id={nodeId}>
<FloatingPortal root={root}>
<FloatingPortal root={effectiveRoot}>
{isOpen && (
<FloatingFocusManager
context={context}
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 @@ -15,6 +15,7 @@ export {
AuthenticateWithRedirectCallback,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
PortalProvider,
} from '@clerk/clerk-react';

export { MultisessionAppSupport } from '@clerk/clerk-react/internal';
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,
PortalProvider,
RedirectToCreateOrganization,
RedirectToOrganizationProfile,
RedirectToSignIn,
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 { PortalProvider } from '@clerk/shared/react';
76 changes: 76 additions & 0 deletions packages/shared/src/react/PortalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
'use client';

import React, { useEffect, useRef } from 'react';

import { createContextAndHook } from './hooks/createContextAndHook';
import { portalRootManager } from './portal-root-manager';

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');

/**
* PortalProvider allows you to specify a custom container for all Clerk floating UI elements
* (popovers, modals, tooltips, etc.) that use 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}>
* <PortalProvider getContainer={() => containerRef.current}>
* <UserButton />
* </PortalProvider>
* </RadixDialog>
* );
* }
* ```
*/
export const PortalProvider = ({ children, getContainer }: PortalProviderProps) => {
const getContainerRef = useRef(getContainer);
getContainerRef.current = getContainer;

// Register with the manager for cross-tree access (e.g., modals in Components.tsx)
useEffect(() => {
const getContainerWrapper = () => getContainerRef.current();
portalRootManager.push(getContainerWrapper);
return () => {
portalRootManager.pop();
};
}, []);

// Provide context for same-tree access (e.g., UserButton popover)
const contextValue = React.useMemo(() => ({ value: { getContainer } }), [getContainer]);

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

/**
* Hook to get the current portal root container.
* First checks React context (for same-tree components),
* then falls back to PortalRootManager (for cross-tree like modals).
*/
export const usePortalRoot = (): HTMLElement | null => {
// Try to get from context first (for components in the same React tree)
const contextValue = usePortalContextWithoutGuarantee();
if (contextValue && 'getContainer' in contextValue) {
return contextValue.getContainer();
}

// Fall back to manager (for components in different React trees, like modals)
return portalRootManager.getCurrent();
};
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 { PortalProvider, usePortalRoot } from './PortalProvider';
37 changes: 37 additions & 0 deletions packages/shared/src/react/portal-root-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* PortalRootManager manages a stack of portal root containers.
* This allows PortalProvider to work across separate React trees
* (e.g., when Clerk modals are rendered in a different tree via Components.tsx).
*/
class PortalRootManager {
private stack: Array<() => HTMLElement | null> = [];

/**
* Push a new portal root getter onto the stack.
* @param getContainer Function that returns the container element
*/
push(getContainer: () => HTMLElement | null): void {
this.stack.push(getContainer);
}

/**
* Pop the most recent portal root from the stack.
*/
pop(): void {
this.stack.pop();
}

/**
* Get the current (topmost) portal root container.
* @returns The container element or null if no provider is active
*/
getCurrent(): HTMLElement | null {
if (this.stack.length === 0) {
return null;
}
const getContainer = this.stack[this.stack.length - 1];
return getContainer();
}
}

export const portalRootManager = new PortalRootManager();