Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
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();