diff --git a/.changeset/mosaic-experimental-export.md b/.changeset/mosaic-experimental-export.md
new file mode 100644
index 00000000000..35b2a1dc9be
--- /dev/null
+++ b/.changeset/mosaic-experimental-export.md
@@ -0,0 +1,21 @@
+---
+'@clerk/nextjs': minor
+'@clerk/react': minor
+'@clerk/ui': minor
+---
+
+Add an experimental subpath for Mosaic components that mount directly in your app's tree rather than being rendered by clerk-js. `UserButton` is the first one. It reads Clerk through hooks, so a `ClerkProvider` above it is all it needs:
+
+```tsx
+import { UserButton } from '@clerk/nextjs/experimental/mosaic';
+```
+
+Pair it with the stylesheet, which carries the design tokens and every component rule:
+
+```css
+@import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk);
+```
+
+The surface and the components behind it will change without a major version while they are experimental.
+
+In `@clerk/ui`, the Mosaic stylesheet moves from `@clerk/ui/styles.css` to `@clerk/ui/experimental/mosaic/styles.css` to sit alongside the components it styles. Update the import if you were using it.
diff --git a/.changeset/mosaic-user-button-custom-pages.md b/.changeset/mosaic-user-button-custom-pages.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-user-button-custom-pages.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/.claude/skills/mosaic/references/stylex.md b/.claude/skills/mosaic/references/stylex.md
index 38f86642269..916beecd46f 100644
--- a/.claude/skills/mosaic/references/stylex.md
+++ b/.claude/skills/mosaic/references/stylex.md
@@ -621,8 +621,8 @@ export interface PopoverPopupProps extends MosaicComponentProps<'div'> { … }
- **Published** (`build:mosaic` → `@stylexjs/rollup-plugin`): compiles the
`styles/index.ts` barrel into `dist-mosaic/styles.css`, exported as
- `@clerk/ui/styles.css`. Consumers choose the cascade layer at import:
- `@import '@clerk/ui/styles.css' layer(components)`.
+ `@clerk/ui/experimental/mosaic/styles.css`. Consumers choose the cascade layer at import:
+ `@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`.
- **Swingset** (source-consumed): `@stylexjs/unplugin/webpack` in `next.config`
transforms StyleX **JS only** (calls → static atoms; SWC/Emotion untouched);
`@stylexjs/postcss-plugin` extracts the **CSS** by replacing `@stylex;` in
diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json
index 700a78d22b0..20690715cf1 100644
--- a/packages/nextjs/package.json
+++ b/packages/nextjs/package.json
@@ -55,6 +55,12 @@
"import": "./dist/esm/experimental.js",
"require": "./dist/cjs/experimental.js"
},
+ "./experimental/mosaic": {
+ "types": "./dist/types/experimental/mosaic.d.ts",
+ "import": "./dist/esm/experimental/mosaic.js",
+ "require": "./dist/cjs/experimental/mosaic.js"
+ },
+ "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css",
"./legacy": {
"types": "./dist/types/legacy.d.ts",
"import": "./dist/esm/legacy.js",
@@ -70,7 +76,7 @@
"webhooks"
],
"scripts": {
- "build": "pnpm clean && tsup",
+ "build": "pnpm clean && tsup && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css",
"build:declarations": "tsc -p tsconfig.declarations.json",
"clean": "rimraf ./dist",
"dev": "tsup --watch",
@@ -78,7 +84,7 @@
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src",
- "lint:attw": "attw --pack . --profile node16 --ignore-rules unexpected-module-syntax",
+ "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16 --ignore-rules unexpected-module-syntax",
"lint:publint": "publint",
"test": "vitest run",
"test:watch": "vitest watch"
@@ -91,6 +97,7 @@
"tslib": "catalog:repo"
},
"devDependencies": {
+ "@clerk/ui": "workspace:*",
"crypto-es": "^2.1.0",
"next": "15.5.19"
},
diff --git a/packages/nextjs/src/experimental/mosaic.ts b/packages/nextjs/src/experimental/mosaic.ts
new file mode 100644
index 00000000000..27da5d32e99
--- /dev/null
+++ b/packages/nextjs/src/experimental/mosaic.ts
@@ -0,0 +1,16 @@
+'use client';
+
+/**
+ * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They
+ * read Clerk via hooks, so a `ClerkProvider` above them is all they need.
+ *
+ * Pair with the stylesheet, which carries the design tokens and every component rule:
+ *
+ * ```css
+ * @import '@clerk/nextjs/experimental/mosaic/styles.css' layer(clerk);
+ * ```
+ *
+ * @experimental The surface and the components behind it are subject to change.
+ */
+export { UserButton } from '@clerk/react/experimental/mosaic';
+export type { UserButtonProps } from '@clerk/react/experimental/mosaic';
diff --git a/packages/react/package.json b/packages/react/package.json
index 210204c6b49..4885138c602 100644
--- a/packages/react/package.json
+++ b/packages/react/package.json
@@ -63,6 +63,17 @@
"default": "./dist/experimental.cjs"
}
},
+ "./experimental/mosaic": {
+ "import": {
+ "types": "./dist/experimental/mosaic.d.mts",
+ "default": "./dist/experimental/mosaic.mjs"
+ },
+ "require": {
+ "types": "./dist/experimental/mosaic.d.cts",
+ "default": "./dist/experimental/mosaic.cjs"
+ }
+ },
+ "./experimental/mosaic/styles.css": "./dist/experimental/mosaic/styles.css",
"./legacy": {
"import": {
"types": "./dist/legacy.d.mts",
@@ -88,14 +99,14 @@
"dist"
],
"scripts": {
- "build": "tsdown",
+ "build": "tsdown && node ../../scripts/copy-mosaic-styles.mjs dist/experimental/mosaic/styles.css",
"clean": "rimraf ./dist",
"dev": "tsdown --watch",
"dev:pub": "pnpm dev --env.publish",
"format": "node ../../scripts/format-package.mjs",
"format:check": "node ../../scripts/format-package.mjs --check",
"lint": "eslint src",
- "lint:attw": "attw --pack . --profile node16",
+ "lint:attw": "attw --pack . --exclude-entrypoints experimental/mosaic/styles.css --profile node16",
"lint:publint": "publint",
"test": "vitest run",
"test:watch": "vitest watch"
diff --git a/packages/react/src/experimental/mosaic.ts b/packages/react/src/experimental/mosaic.ts
new file mode 100644
index 00000000000..1c841f4012f
--- /dev/null
+++ b/packages/react/src/experimental/mosaic.ts
@@ -0,0 +1,16 @@
+'use client';
+
+/**
+ * Mosaic components mounted directly in the host app's tree, rather than through clerk-js. They
+ * read Clerk via `@clerk/shared/react` hooks, so a `ClerkProvider` above them is all they need.
+ *
+ * Pair with the stylesheet, which carries the design tokens and every component rule:
+ *
+ * ```css
+ * @import '@clerk/react/experimental/mosaic/styles.css' layer(clerk);
+ * ```
+ *
+ * @experimental The surface and the components behind it are subject to change.
+ */
+export { UserButton } from '@clerk/ui/experimental/mosaic';
+export type { UserButtonProps } from '@clerk/ui/experimental/mosaic';
diff --git a/packages/react/tsdown.config.mts b/packages/react/tsdown.config.mts
index 3b2d3521d6c..de1302b7242 100644
--- a/packages/react/tsdown.config.mts
+++ b/packages/react/tsdown.config.mts
@@ -63,6 +63,7 @@ export default defineConfig((overrideOptions: Options) => {
internal: 'src/internal.ts',
errors: 'src/errors.ts',
experimental: 'src/experimental.ts',
+ 'experimental/mosaic': 'src/experimental/mosaic.ts',
legacy: 'src/legacy.ts',
types: 'src/types/index.ts',
},
@@ -76,7 +77,12 @@ export default defineConfig((overrideOptions: Options) => {
// Bundle @clerk/ui/register inline at build time so consumers don't need
// @clerk/ui as a dependency. The registration code sets up globalThis.__clerkSharedModules
// to enable @clerk/ui's shared variant to use the host app's React.
- noExternal: ['@clerk/ui/register'],
+ //
+ // The Mosaic entry is inlined for the same reason: left external, the re-export resolves from
+ // the consumer's tree at runtime, which makes @clerk/ui a dependency and installs its whole
+ // graph (Emotion, the Solana wallet adapters, ...) for every consumer, Mosaic or not. Its build
+ // already bundles everything except React and @clerk/shared, both of which we ship anyway.
+ noExternal: ['@clerk/ui/register', '@clerk/ui/experimental/mosaic'],
define: {
PACKAGE_NAME: `"${pkgJson.name}"`,
PACKAGE_VERSION: `"${pkgJson.version}"`,
diff --git a/packages/swingset/src/stories/menu.component.mdx b/packages/swingset/src/stories/menu.component.mdx
index b1a749eaeea..33047d7ef42 100644
--- a/packages/swingset/src/stories/menu.component.mdx
+++ b/packages/swingset/src/stories/menu.component.mdx
@@ -120,10 +120,10 @@ const [open, setOpen] = useState(false);
Unlike the slot-recipe components, the Mosaic menu is themed with **StyleX**. Each styled part
carries a stable `.cl-` class (the slots above) alongside the StyleX atoms. Consumers never
target the hashed atomic classes — override by targeting the `.cl-*` slot from a CSS layer that wins
-over `@clerk/ui/styles.css`:
+over `@clerk/ui/experimental/mosaic/styles.css`:
```css
-@import '@clerk/ui/styles.css' layer(components);
+@import '@clerk/ui/experimental/mosaic/styles.css' layer(components);
@layer overrides {
.cl-menu-popup {
diff --git a/packages/swingset/src/stories/popover.component.mdx b/packages/swingset/src/stories/popover.component.mdx
index e1b8e2aecad..a2145f370e0 100644
--- a/packages/swingset/src/stories/popover.component.mdx
+++ b/packages/swingset/src/stories/popover.component.mdx
@@ -189,10 +189,10 @@ them through your own typography (`Heading`, `Text`) inside the surface.
Unlike the slot-recipe components, the Mosaic popover is themed with **StyleX**. Each styled part
carries a stable `.cl-` class (the slots in the table above) alongside the StyleX atoms.
Consumers never target the hashed atomic classes — override by targeting the `.cl-*` slot from a
-CSS layer that wins over `@clerk/ui/styles.css`:
+CSS layer that wins over `@clerk/ui/experimental/mosaic/styles.css`:
```css
-@import '@clerk/ui/styles.css' layer(components);
+@import '@clerk/ui/experimental/mosaic/styles.css' layer(components);
@layer overrides {
.cl-popover-popup[data-size='lg'] {
diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index 3b818b7738c..8f1bf28bec5 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -33,6 +33,8 @@
{ "path": "./dist/op-plans-page*.js", "maxSize": "3KB" },
{ "path": "./dist/statement-page*.js", "maxSize": "5KB" },
{ "path": "./dist/payment-attempt-page*.js", "maxSize": "4KB" },
- { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" }
+ { "path": "./dist/web3-solana-wallet-buttons*.js", "maxSize": "85KB" },
+ { "path": "./dist-mosaic/styles.css", "maxSize": "8KB" },
+ { "path": "./dist-mosaic/index.js", "maxSize": "88KB" }
]
}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index cd7d5172288..18a328b8e3d 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -57,7 +57,12 @@
"default": "./dist/experimental/index.js"
},
"./themes/shadcn.css": "./dist/themes/shadcn.css",
- "./styles.css": {
+ "./experimental/mosaic": {
+ "types": "./dist-mosaic/index.d.ts",
+ "import": "./dist-mosaic/index.js",
+ "default": "./dist-mosaic/index.js"
+ },
+ "./experimental/mosaic/styles.css": {
"types": "./styles.css.d.ts",
"default": "./dist-mosaic/styles.css"
},
@@ -81,7 +86,7 @@
"register"
],
"scripts": {
- "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm type-check",
+ "build": "pnpm build:umd && pnpm build:esm && pnpm build:mosaic && pnpm check:no-rhc && pnpm check:no-emotion && pnpm type-check",
"build:analyze": "rspack build --config rspack.config.js --env production --env analyze",
"build:esm": "tsdown",
"build:mosaic": "tsdown --config tsdown.mosaic.config.mts",
@@ -89,6 +94,7 @@
"build:umd": "rspack build --config rspack.config.js --env production",
"bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
"bundlewatch:fix": "node bundlewatch-fix.mjs",
+ "check:no-emotion": "node scripts/check-mosaic-emotion-free.mjs",
"check:no-rhc": "node ../../scripts/search-for-rhc.mjs directory dist/no-rhc",
"clean": "rimraf ./dist",
"dev": "rspack serve --config rspack.config.js",
@@ -116,7 +122,6 @@
"@solana/wallet-adapter-base": "catalog:module-manager",
"@solana/wallet-adapter-react": "catalog:module-manager",
"@solana/wallet-standard": "catalog:module-manager",
- "@stylexjs/stylex": "0.19.0",
"@swc/helpers": "catalog:repo",
"copy-to-clipboard": "3.3.3",
"core-js": "catalog:repo",
@@ -135,6 +140,7 @@
"@rspack/plugin-react-refresh": "catalog:rspack",
"@stylexjs/eslint-plugin": "0.19.0",
"@stylexjs/rollup-plugin": "0.19.0",
+ "@stylexjs/stylex": "0.19.0",
"@stylexjs/unplugin": "0.19.0",
"@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^6.5.1",
diff --git a/packages/ui/scripts/check-mosaic-emotion-free.mjs b/packages/ui/scripts/check-mosaic-emotion-free.mjs
new file mode 100644
index 00000000000..8d4e07f4020
--- /dev/null
+++ b/packages/ui/scripts/check-mosaic-emotion-free.mjs
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+
+/**
+ * The `build:mosaic` entry is published as `@clerk/ui/experimental/mosaic` and mounted directly in
+ * host apps, so it must stay Emotion-free: pulling `@emotion/react` in ships a second styling
+ * runtime to every consumer. Nothing about the barrel enforces that — one legacy component reached
+ * from the graph (an `sx` prop, a `Box`, a `keyframes`) drags it back in silently. This fails the
+ * build instead.
+ */
+
+import { readFileSync } from 'node:fs';
+
+const BUNDLE = new URL('../dist-mosaic/index.js', import.meta.url);
+
+const source = readFileSync(BUNDLE, 'utf8');
+const offenders = source.split('\n').filter(line => line.includes('@emotion'));
+
+if (offenders.length > 0) {
+ console.error(`Found Emotion in the Mosaic build output (dist-mosaic/index.js):\n${offenders.join('\n')}`);
+ process.exit(1);
+}
+
+console.log('✅ No Emotion found in the Mosaic build output');
diff --git a/packages/ui/src/mosaic/components/menu/index.ts b/packages/ui/src/mosaic/components/menu/index.ts
index f05e07c1ec1..e1c23cc0be0 100644
--- a/packages/ui/src/mosaic/components/menu/index.ts
+++ b/packages/ui/src/mosaic/components/menu/index.ts
@@ -1,2 +1,2 @@
export { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from './menu';
-export type { MenuContentProps, MenuItemProps, MenuProps, MenuSeparatorProps, MenuTriggerProps } from './menu';
+export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from './menu';
diff --git a/packages/ui/src/mosaic/components/menu/menu.tsx b/packages/ui/src/mosaic/components/menu/menu.tsx
index ecff3266ff5..96ba671c2ca 100644
--- a/packages/ui/src/mosaic/components/menu/menu.tsx
+++ b/packages/ui/src/mosaic/components/menu/menu.tsx
@@ -2,7 +2,6 @@ import type {
MenuItemProps as PrimitiveMenuItemProps,
MenuPopupProps,
MenuPortalProps,
- MenuProps,
MenuSeparatorProps,
} from '@clerk/headless/menu';
import { Menu as Primitive } from '@clerk/headless/menu';
@@ -16,8 +15,6 @@ import { Icon } from '../icon';
import { reset } from '../reset.styles';
import { styles } from './menu.styles';
-export type { MenuProps, MenuSeparatorProps };
-
export type MenuTriggerProps = MosaicComponentProps<'button'>;
/**
diff --git a/packages/ui/src/mosaic/components/popover/index.ts b/packages/ui/src/mosaic/components/popover/index.ts
index 8ac0cb8c76b..7c72cb1ccbe 100644
--- a/packages/ui/src/mosaic/components/popover/index.ts
+++ b/packages/ui/src/mosaic/components/popover/index.ts
@@ -3,7 +3,6 @@ export type {
PopoverCloseProps,
PopoverDescriptionProps,
PopoverPopupProps,
- PopoverRootProps,
PopoverSize,
PopoverTitleProps,
PopoverTriggerProps,
diff --git a/packages/ui/src/mosaic/components/popover/popover.tsx b/packages/ui/src/mosaic/components/popover/popover.tsx
index 20139f5d0ce..5f4bb1b2bb1 100644
--- a/packages/ui/src/mosaic/components/popover/popover.tsx
+++ b/packages/ui/src/mosaic/components/popover/popover.tsx
@@ -1,4 +1,3 @@
-import type { PopoverProps as HeadlessPopoverProps } from '@clerk/headless/popover';
import { Popover as Primitive } from '@clerk/headless/popover';
import * as stylex from '@stylexjs/stylex';
import React from 'react';
@@ -10,8 +9,6 @@ import { sizes, styles } from './popover.styles';
export type PopoverSize = 'sm' | 'md' | 'lg';
-export type PopoverRootProps = HeadlessPopoverProps;
-
/**
* The headless parts type their props (and the `render` callback's argument) against
* the raw tag props, which carry the non-standard HTML `color` attribute typed
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
new file mode 100644
index 00000000000..e209673b17a
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/__tests__/useCustomPages.test.tsx
@@ -0,0 +1,212 @@
+import type { CustomPage } from '@clerk/shared/types';
+import { act, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import type { CustomPagesOptions, CustomProfileItem } from '../useCustomPages';
+import { useCustomPages } from '../useCustomPages';
+
+// The bridge's other half lives in clerk-js: `ExternalElementMounter` renders a `div` and hands it to
+// `mount`, then hands it back to `unmount` when the profile goes away. These stand in for it, so the
+// tests exercise the same handshake the real modal performs.
+function mountInto(callback: ((el: HTMLDivElement) => void) | undefined): HTMLDivElement {
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ act(() => callback?.(el));
+ return el;
+}
+
+function unmountFrom(callback: ((el?: HTMLDivElement) => void) | undefined, el: HTMLDivElement) {
+ act(() => callback?.(el));
+ el.remove();
+}
+
+let emitted: CustomPage[] | undefined;
+
+function Harness({ items, order, builtInPages = ['account', 'security'] }: Partial) {
+ const { customPages, portals } = useCustomPages({ items, order, builtInPages });
+ emitted = customPages;
+ return {portals}
;
+}
+
+const terms: CustomProfileItem = {
+ label: 'Terms',
+ path: 'terms',
+ icon: terms icon,
+ content: Terms body
,
+};
+
+const docs: CustomProfileItem = {
+ label: 'Docs',
+ path: 'docs',
+ href: 'https://clerk.com/docs',
+ icon: docs icon,
+};
+
+beforeEach(() => {
+ emitted = undefined;
+});
+
+describe('useCustomPages', () => {
+ it('sends nothing when there are no custom pages', () => {
+ render();
+
+ expect(emitted).toBeUndefined();
+ expect(screen.getByTestId('host')).toBeEmptyDOMElement();
+ });
+
+ it('sends a page as its path and a link as its href', () => {
+ render();
+
+ expect(emitted?.map(page => page.url)).toEqual(['terms', 'https://clerk.com/docs']);
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ // clerk-js tells a page from a link by which callbacks are present, so content callbacks are what
+ // make an item a page. A link carrying them would be routed to instead of followed.
+ it('sends content callbacks for a page and none for a link', () => {
+ render();
+
+ const [page, link] = emitted ?? [];
+ expect(page.mount).toBeTypeOf('function');
+ expect(page.unmount).toBeTypeOf('function');
+ expect(link.mount).toBeUndefined();
+ expect(link.unmount).toBeUndefined();
+ });
+
+ // The same presence check rejects any item missing an icon pair outright, so the callbacks go out
+ // whether or not there is an icon to put through them. Without this, `icon` could not be optional:
+ // leaving it off would drop the page from the profile with no explanation.
+ it('sends the icon callbacks even for an item with no icon', () => {
+ render(Terms body
}]} />);
+
+ const [page] = emitted ?? [];
+ expect(page.mountIcon).toBeTypeOf('function');
+ expect(page.unmountIcon).toBeTypeOf('function');
+
+ const el = mountInto(page.mountIcon);
+ expect(el).toBeEmptyDOMElement();
+ });
+
+ it('renders page content into the element clerk-js hands back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('renders an icon into its own element, apart from the content', () => {
+ render();
+
+ const content = mountInto(emitted?.[0].mount);
+ const icon = mountInto(emitted?.[0].mountIcon);
+
+ expect(within(icon).getByText('terms icon')).toBeInTheDocument();
+ expect(within(content).queryByText('terms icon')).toBeNull();
+ });
+
+ it('keeps each page in the element that asked for it', () => {
+ const help: CustomProfileItem = { label: 'Help', path: 'help', content: Help body
};
+ render();
+
+ const first = mountInto(emitted?.[0].mount);
+ const second = mountInto(emitted?.[1].mount);
+
+ expect(within(first).getByText('Terms body')).toBeInTheDocument();
+ expect(within(second).getByText('Help body')).toBeInTheDocument();
+ });
+
+ it('stops rendering content once clerk-js gives the element back', () => {
+ render();
+
+ const el = mountInto(emitted?.[0].mount);
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+
+ unmountFrom(emitted?.[0].unmount, el);
+
+ expect(screen.queryByText('Terms body')).toBeNull();
+ });
+
+ // The profile is opened once with the callbacks from that render, and never handed a later set.
+ // They have to keep working against the current content, or a page re-rendered while the profile
+ // is open goes stale.
+ it('renders updated content through the callbacks the profile was opened with', () => {
+ const { rerender } = render();
+ const el = mountInto(emitted?.[0].mount);
+
+ rerender(Revised terms }]} />);
+
+ expect(within(el).getByText('Revised terms')).toBeInTheDocument();
+ });
+
+ describe('order', () => {
+ it('leaves the built-in pages alone when no order is given', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'Docs']);
+ });
+
+ it('sends the pages in the order it was given', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'Terms', 'account', 'Docs']);
+ });
+
+ // clerk-js takes a request to move a built-in page as the page's id and nothing else; anything
+ // more and it reads as a custom page instead.
+ it('sends a built-in page as its id alone', () => {
+ render();
+
+ expect(emitted).toEqual([{ label: 'security' }, { label: 'account' }]);
+ });
+
+ // clerk-js puts a built-in page it was not sent *before* every page it was, so leaving one out
+ // of the order would jump it to the front rather than leave it where it was.
+ it('sends the pages left out of the order after the ones in it', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security', 'billing', 'Docs']);
+ });
+
+ it('drops an id that belongs to no page', () => {
+ render(
+ ,
+ );
+
+ expect(emitted?.map(page => page.label)).toEqual(['Terms', 'account', 'security']);
+ });
+
+ it('sends a page once even when the order names it twice', () => {
+ render();
+
+ expect(emitted?.map(page => page.label)).toEqual(['security', 'account']);
+ });
+
+ it('renders a reordered page into the element clerk-js hands back', () => {
+ render(
+ ,
+ );
+
+ const el = mountInto(emitted?.[1].mount);
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/packages/ui/src/mosaic/hooks/useCustomPages.tsx b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
new file mode 100644
index 00000000000..f406a0d3dad
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useCustomPages.tsx
@@ -0,0 +1,142 @@
+import type { CustomPage } from '@clerk/shared/types';
+import type { ReactNode } from 'react';
+import { useCallback, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+/** A page of your own inside the profile, reached from its navigation. */
+export interface CustomProfilePage {
+ /** Names the page in the profile's navigation. */
+ label: string;
+ /** Where the page lives, relative to the profile root. Absolute URLs are rejected. */
+ path: string;
+ href?: never;
+ icon?: ReactNode;
+ /** Rendered as the page itself. */
+ content: ReactNode;
+}
+
+/** A row in the profile's navigation that leaves for somewhere else. */
+export interface CustomProfileLink {
+ /** Names the row in the profile's navigation. */
+ label: string;
+ /** Identifies the row, for ordering. */
+ path: string;
+ /** Where the row goes. */
+ href: string;
+ icon?: ReactNode;
+ content?: never;
+}
+
+export type CustomProfileItem = CustomProfilePage | CustomProfileLink;
+
+export interface CustomPagesOptions {
+ /** Pages and links of the consumer's own. */
+ items: CustomProfileItem[] | undefined;
+ /** The order the profile's navigation should run in, by id. */
+ order: readonly string[] | undefined;
+ /** The profile's own pages, in the order it shows them, minus any this instance has turned off. */
+ builtInPages: readonly string[];
+}
+
+export interface CustomPagesBridge {
+ /** clerk-js's own custom-page form, ready to pass to `openUserProfile`. */
+ customPages: CustomPage[] | undefined;
+ /** Render these for as long as the profile can be open, or its pages come up blank. */
+ portals: ReactNode[];
+}
+
+const isLink = (item: CustomProfileItem): item is CustomProfileLink => item.href !== undefined;
+
+/**
+ * The ids to send, in the order the profile should show them.
+ *
+ * clerk-js puts every built-in page it was *not* asked to move ahead of everything it was, so a
+ * built-in left out of the order has to be sent anyway to keep it behind the pages that were named.
+ * Ids that match no page are dropped rather than sent: clerk-js would reject them, and does so by
+ * logging them as invalid page data, which is not what a typo in this list deserves.
+ */
+function arrange(
+ order: readonly string[],
+ items: ReadonlyMap,
+ builtInPages: readonly string[],
+): string[] {
+ const exists = (id: string) => items.has(id) || builtInPages.includes(id);
+ const named = [...new Set(order)].filter(exists);
+ const rest = [...builtInPages, ...items.keys()].filter(id => !named.includes(id));
+ return [...named, ...rest];
+}
+
+function portalInto(containers: ReadonlyMap, id: string, node: ReactNode): ReactNode {
+ const container = containers.get(id);
+ return container ? createPortal(node, container, id) : null;
+}
+
+/**
+ * Bridges custom pages written as React nodes into the DOM callbacks clerk-js takes.
+ *
+ * The profile opens in clerk-js's own React root, which cannot render a node from the host app's
+ * tree. So each page is sent as a `mount`/`unmount` pair: clerk-js renders an empty `div` where the
+ * page belongs and hands it over, and the host tree portals the content into it from here. The
+ * portals therefore have to stay mounted in the host tree the whole time the profile is open, which
+ * is why they come back out rather than being rendered here.
+ *
+ * This is the shape of the bridge only for as long as the profile renders outside the host tree. A
+ * Mosaic profile mounted in-tree renders `content` directly, and none of this survives except the
+ * props a consumer writes.
+ */
+export function useCustomPages({ items, order, builtInPages }: CustomPagesOptions): CustomPagesBridge {
+ const [containers, setContainers] = useState>(new Map());
+
+ // Keyed by id rather than closing over the element, so the callbacks a profile was opened with keep
+ // working: the portal re-reads its container from state on every render of the host tree.
+ const bind = useCallback(
+ (id: string) => ({
+ mount: (el: HTMLDivElement) => setContainers(prev => new Map(prev).set(id, el)),
+ unmount: () =>
+ setContainers(prev => {
+ const next = new Map(prev);
+ next.delete(id);
+ return next;
+ }),
+ }),
+ [],
+ );
+
+ const byId = new Map((items ?? []).map(item => [item.path, item]));
+ const ids = order?.length ? arrange(order, byId, builtInPages) : [...byId.keys()];
+
+ if (!ids.length) {
+ return { customPages: undefined, portals: [] };
+ }
+
+ const customPages = ids.map(id => {
+ const item = byId.get(id);
+ // A built-in page, which clerk-js moves on nothing but its id. Anything else attached to it and
+ // it reads as a custom page instead.
+ if (!item) {
+ return { label: id };
+ }
+
+ // clerk-js decides what an item *is* from which callbacks are present, and drops one missing an
+ // icon pair as invalid. So the icon callbacks go out whether or not there is an icon to put
+ // through them; without them, leaving `icon` off would silently cost you the page.
+ const icon = bind(`icon:${id}`);
+ const content = isLink(item) ? undefined : bind(`content:${id}`);
+
+ return {
+ label: item.label,
+ // A page is routed to by its path; a link is followed to wherever it points.
+ url: isLink(item) ? item.href : item.path,
+ mountIcon: icon.mount,
+ unmountIcon: icon.unmount,
+ ...(content && { mount: content.mount, unmount: content.unmount }),
+ };
+ });
+
+ const portals = (items ?? []).flatMap(item => [
+ portalInto(containers, `icon:${item.path}`, item.icon),
+ ...(isLink(item) ? [] : [portalInto(containers, `content:${item.path}`, item.content)]),
+ ]);
+
+ return { customPages, portals };
+}
diff --git a/packages/ui/src/mosaic/hooks/useUserProfilePages.ts b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts
new file mode 100644
index 00000000000..88c731d6d9c
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useUserProfilePages.ts
@@ -0,0 +1,33 @@
+import {
+ disabledUserAPIKeysFeature,
+ disabledUserBillingFeature,
+} from '@clerk/shared/internal/clerk-js/componentGuards';
+import { useClerk } from '@clerk/shared/react';
+
+import { useMosaicEnvironment } from './useMosaicEnvironment';
+
+/** A page the UserProfile brings itself, named by the id its navigation knows it as. */
+export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys';
+
+/**
+ * The UserProfile's own pages, in the order it lists them, minus the ones this instance has turned
+ * off.
+ *
+ * Ordering a custom page after a built-in one means naming every built-in that follows it, so the
+ * list has to match what the profile will actually show. It mirrors clerk-js rather than being read
+ * from it: the profile is not mounted yet at the point this is needed, and it decides its own pages
+ * from the same environment behind the same guards.
+ */
+export function useUserProfilePages(): UserProfilePageId[] {
+ const clerk = useClerk();
+ const environment = useMosaicEnvironment();
+
+ const pages: UserProfilePageId[] = ['account', 'security'];
+ if (!disabledUserBillingFeature(clerk, environment)) {
+ pages.push('billing');
+ }
+ if (!disabledUserAPIKeysFeature(clerk, environment)) {
+ pages.push('apiKeys');
+ }
+ return pages;
+}
diff --git a/packages/ui/src/mosaic/index.ts b/packages/ui/src/mosaic/index.ts
new file mode 100644
index 00000000000..f029a76256f
--- /dev/null
+++ b/packages/ui/src/mosaic/index.ts
@@ -0,0 +1,7 @@
+// Public entry for `@clerk/ui/experimental/mosaic`. The side-effect import keeps every migrated
+// component in the StyleX graph so the emitted `styles.css` stays complete, without making them API:
+// `./styles` is the build barrel, and re-exporting it would publish the headless primitive types too.
+import './styles';
+
+export { UserButton } from './user-button/user-button';
+export type { UserButtonProps } from './user-button/user-button';
diff --git a/packages/ui/src/mosaic/styles/index.ts b/packages/ui/src/mosaic/styles/index.ts
index 9f5d0813832..727930ec431 100644
--- a/packages/ui/src/mosaic/styles/index.ts
+++ b/packages/ui/src/mosaic/styles/index.ts
@@ -6,6 +6,9 @@
export type { MosaicComponentProps, MosaicElementProps } from '../props';
+export { UserButton } from '../user-button/user-button';
+export type { UserButtonProps } from '../user-button/user-button';
+
export { Avatar } from '../components/avatar';
export type { AvatarProps, AvatarImageProps, AvatarFallbackProps } from '../components/avatar';
export { Badge } from '../components/badge';
@@ -23,13 +26,7 @@ export type { InputProps } from '../components/input';
export { Item } from '../components/item';
export type { ItemProps } from '../components/item';
export { Menu } from '../components/menu';
-export type {
- MenuContentProps,
- MenuItemProps,
- MenuProps,
- MenuSeparatorProps,
- MenuTriggerProps,
-} from '../components/menu';
+export type { MenuContentProps, MenuItemProps, MenuTriggerProps } from '../components/menu';
export { scrollAreaRoot, scrollAreaVars, scrollAreaViewport } from '../components/scroll-area';
export type { ScrollAreaGutter } from '../components/scroll-area';
export { Spinner } from '../components/spinner';
@@ -42,7 +39,6 @@ export type {
PopoverCloseProps,
PopoverDescriptionProps,
PopoverPopupProps,
- PopoverRootProps,
PopoverSize,
PopoverTitleProps,
PopoverTriggerProps,
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
index 98b0ba9dc00..5057f2e8dfb 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.controller.test.tsx
@@ -1,4 +1,5 @@
import type * as SharedReact from '@clerk/shared/react';
+import type { CustomPage } from '@clerk/shared/types';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -169,8 +170,8 @@ afterEach(() => {
vi.clearAllMocks();
});
-function Harness(options: UserButtonControllerOptions = {}) {
- const c = useUserButtonController(options);
+function Harness({ customPages, ...options }: UserButtonControllerOptions & { customPages?: CustomPage[] } = {}) {
+ const c = useUserButtonController(options, customPages);
if (c.status !== 'ready') {
return ;
}
@@ -588,6 +589,26 @@ describe('useUserButtonController', () => {
expect(openOrganizationProfile).toHaveBeenCalledWith({ getContainer });
});
+ // Custom pages are bridged into this DOM-callback form by the container, since it is the layer
+ // that can render their portals. All the controller owes them is a ride to the modal.
+ it('hands the profile modal the custom pages it was given', () => {
+ const customPages = [
+ {
+ label: 'Terms',
+ url: 'terms',
+ mount: vi.fn(),
+ unmount: vi.fn(),
+ mountIcon: vi.fn(),
+ unmountIcon: vi.fn(),
+ },
+ ];
+ render();
+
+ fireEvent.click(screen.getByText('manage-account'));
+
+ expect(openUserProfile).toHaveBeenCalledWith({ getContainer, customPages });
+ });
+
// A URL is the whole opt-in: passing one means navigation, with no mode to remember to pass
// alongside it. The two are resolved apart, so routing one profile leaves the other a modal.
it('navigates to a profile URL when one is given, and only for that profile', () => {
diff --git a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
index af3499a53d2..38b98d2260e 100644
--- a/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
+++ b/packages/ui/src/mosaic/user-button/__tests__/user-button.integration.test.tsx
@@ -1,5 +1,6 @@
import type * as SharedReact from '@clerk/shared/react';
-import { render, screen, waitFor, within } from '@testing-library/react';
+import type { CustomPage } from '@clerk/shared/types';
+import { act as reactAct, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -84,6 +85,8 @@ vi.mock('@clerk/shared/react', async importOriginal => {
displayConfig: { afterSwitchSessionUrl: '/after-switch' },
authConfig: { singleSessionMode },
organizationSettings: { enabled: organizationsEnabled },
+ commerceSettings: { billing: { user: { enabled: false } } },
+ apiKeysSettings: { user_api_keys_enabled: false },
},
}),
};
@@ -390,6 +393,48 @@ describe('UserButton (connected)', () => {
await waitFor(() => expect(popup()).toBeNull());
});
+ // The whole round trip for a custom page: the prop a consumer writes, through the bridge, out to
+ // the callbacks clerk-js is handed, and back into the element clerk-js renders for the page. The
+ // popover has closed by then, so this also covers the portals outliving what opened them.
+ it('renders a custom page into the element the opened profile hands back', async () => {
+ renderUserButton({
+ userProfileProps: { customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}] },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages).toHaveLength(1);
+ expect(customPages[0]).toMatchObject({ label: 'Terms', url: 'terms' });
+
+ // Stands in for clerk-js's `ExternalElementMounter`, which renders this `div` where the page goes.
+ const el = document.createElement('div');
+ document.body.appendChild(el);
+ reactAct(() => {
+ customPages[0].mount(el);
+ });
+
+ expect(within(el).getByText('Terms body')).toBeInTheDocument();
+ });
+
+ it('opens the profile with its pages in the order it was given', async () => {
+ renderUserButton({
+ userProfileProps: {
+ customPages: [{ label: 'Terms', path: 'terms', content: Terms body
}],
+ pageOrder: ['account', 'terms'],
+ },
+ });
+ const act = await open();
+
+ await accountAction(act, 'Manage account');
+ await waitFor(() => expect(popup()).toBeNull());
+
+ const { customPages } = openUserProfile.mock.calls[0][0];
+ expect(customPages.map((page: CustomPage) => page.label)).toEqual(['account', 'Terms', 'security']);
+ });
+
it('inviting members opens the InviteMembers modal and closes the popover', async () => {
renderUserButton();
const act = await open();
diff --git a/packages/ui/src/mosaic/user-button/user-button.controller.tsx b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
index ac802fc4806..bd5e36a6687 100644
--- a/packages/ui/src/mosaic/user-button/user-button.controller.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.controller.tsx
@@ -1,6 +1,6 @@
import { getFullName, getIdentifier } from '@clerk/shared/internal/clerk-js/user';
import { useClerk, useOrganization, usePortalRoot, useSession, useUser } from '@clerk/shared/react';
-import type { OrganizationResource, UserResource } from '@clerk/shared/types';
+import type { CustomPage, OrganizationResource, UserResource } from '@clerk/shared/types';
import { populateParamFromObject } from '../../contexts/utils';
import { useOrganizationListInView } from '../../hooks/useOrganizationListInView';
@@ -124,7 +124,15 @@ function toSession(sessionId: string, user: UserResource): UserButtonSession {
};
}
-export function useUserButtonController(options?: UserButtonControllerOptions): UserButtonController {
+/**
+ * @param userProfileCustomPages - The consumer's custom pages, already bridged into clerk-js's
+ * DOM-callback form. The container owns that conversion because it is the layer that can render
+ * the portals behind it, so they arrive here ready to forward and stay out of the public options.
+ */
+export function useUserButtonController(
+ options?: UserButtonControllerOptions,
+ userProfileCustomPages?: CustomPage[],
+): UserButtonController {
const { isLoaded: isUserLoaded, user } = useUser();
const { isLoaded: isSessionLoaded, session } = useSession();
const { isLoaded: isOrgLoaded, organization } = useOrganization();
@@ -145,7 +153,7 @@ export function useUserButtonController(options?: UserButtonControllerOptions):
const manageAccount = openOrNavigate({
url: options?.userProfileUrl,
mode: options?.userProfileMode,
- openModal: () => clerk.openUserProfile({ getContainer }),
+ openModal: () => clerk.openUserProfile({ getContainer, customPages: userProfileCustomPages }),
buildUrl: () => clerk.buildUserProfileUrl(),
navigate: router.navigate,
});
diff --git a/packages/ui/src/mosaic/user-button/user-button.test-d.ts b/packages/ui/src/mosaic/user-button/user-button.test-d.ts
new file mode 100644
index 00000000000..e1fe0cb19e6
--- /dev/null
+++ b/packages/ui/src/mosaic/user-button/user-button.test-d.ts
@@ -0,0 +1,177 @@
+import type { OrganizationResource, UserResource } from '@clerk/shared/types';
+import { describe, expectTypeOf, test } from 'vitest';
+
+import type { UserButtonProps } from '../index';
+
+// The published surface of `@clerk/nextjs/experimental/mosaic`, imported the way a consumer gets it.
+// Object literals reach `accept` the same way JSX attributes reach the component, excess-property
+// checking and all, so a rejection here is a rejection a consumer would see.
+//
+// Rejections stay on one line: `@ts-expect-error` only covers the line that follows it, and a
+// literal spread over several lines can report its error on any of them.
+const accept = (props: UserButtonProps): UserButtonProps => props;
+
+// ─── The surface as a whole ──────────────────────────────────────────────────
+
+describe('UserButtonProps — nothing is required', () => {
+ test('the button takes no props at all', () => {
+ accept({});
+ });
+
+ test('a misspelled prop is caught rather than silently ignored', () => {
+ // @ts-expect-error — `userProfileUrls` is not a prop
+ accept({ userProfileUrls: '/account' });
+ });
+});
+
+// ─── mode / modePriority ─────────────────────────────────────────────────────
+
+describe('mode and modePriority — the two vocabularies stay apart', () => {
+ test('mode is the three switcher shapes', () => {
+ expectTypeOf().toEqualTypeOf<'combined' | 'orgs' | 'user' | undefined>();
+ });
+
+ test('modePriority names the organization in full, unlike mode', () => {
+ expectTypeOf().toEqualTypeOf<'organizations' | 'user' | undefined>();
+ });
+
+ test('every mode is accepted', () => {
+ accept({ mode: 'combined' });
+ accept({ mode: 'orgs' });
+ accept({ mode: 'user' });
+ });
+
+ test('a mode outside the union is rejected', () => {
+ // @ts-expect-error — 'organizations' is modePriority's word, not mode's
+ accept({ mode: 'organizations' });
+ });
+
+ test("modePriority does not take mode's abbreviation", () => {
+ // @ts-expect-error — 'orgs' is mode's word, not modePriority's
+ accept({ modePriority: 'orgs' });
+ });
+});
+
+// ─── Routing: url and mode cannot contradict each other ──────────────────────
+
+describe('profile routing — a URL is the whole opt-in to navigation', () => {
+ test('a URL alone routes; naming navigation alongside it is allowed', () => {
+ accept({ userProfileUrl: '/account' });
+ accept({ userProfileUrl: '/account', userProfileMode: 'navigation' });
+ accept({ organizationProfileUrl: '/org', organizationProfileMode: 'navigation' });
+ accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'navigation' });
+ });
+
+ test('modal is the default, and stands on its own', () => {
+ accept({ userProfileMode: 'modal' });
+ accept({ organizationProfileMode: 'modal' });
+ accept({ createOrganizationMode: 'modal' });
+ });
+
+ test('a URL cannot ask for a modal', () => {
+ // @ts-expect-error — a URL means navigation; 'modal' contradicts it
+ accept({ userProfileUrl: '/account', userProfileMode: 'modal' });
+ // @ts-expect-error — same contradiction on the organization profile
+ accept({ organizationProfileUrl: '/org', organizationProfileMode: 'modal' });
+ // @ts-expect-error — same contradiction on create-organization
+ accept({ createOrganizationUrl: '/org/new', createOrganizationMode: 'modal' });
+ });
+
+ test('the three surfaces are configured apart — routing one leaves the others modal', () => {
+ accept({ userProfileUrl: '/account', organizationProfileMode: 'modal' });
+ });
+});
+
+describe('after-select URLs — each builder gets the entity it resolves against', () => {
+ test('a path template is accepted', () => {
+ accept({ afterSelectOrganizationUrl: '/orgs/:slug', afterSelectPersonalUrl: '/me' });
+ });
+
+ test('the organization builder receives an organization', () => {
+ accept({
+ afterSelectOrganizationUrl: organization => {
+ expectTypeOf(organization).toEqualTypeOf();
+ return `/orgs/${organization.id}`;
+ },
+ });
+ });
+
+ test('the personal builder receives the user, not an organization', () => {
+ accept({
+ afterSelectPersonalUrl: user => {
+ expectTypeOf(user).toEqualTypeOf();
+ return `/users/${user.id}`;
+ },
+ });
+ });
+
+ test('a builder must return a string', () => {
+ // @ts-expect-error — the URL is what gets navigated to; there is nothing to do with a number
+ accept({ afterSelectOrganizationUrl: () => 42 });
+ });
+});
+
+// ─── Custom menu items ───────────────────────────────────────────────────────
+
+describe('customMenuItems — a row either acts or leaves', () => {
+ test('an action row', () => {
+ accept({ customMenuItems: [{ id: 'support', label: 'Contact support', onClick: () => {} }] });
+ });
+
+ test('a link row', () => {
+ accept({ customMenuItems: [{ id: 'docs', label: 'Documentation', href: 'https://example.com' }] });
+ });
+
+ test('a row cannot do both', () => {
+ // @ts-expect-error — `href` and `onClick` are mutually exclusive
+ accept({ customMenuItems: [{ id: 'x', label: 'X', href: '/x', onClick: () => {} }] });
+ });
+
+ test('a row must do one', () => {
+ // @ts-expect-error — a row with neither `href` nor `onClick` does nothing
+ accept({ customMenuItems: [{ id: 'x', label: 'X' }] });
+ });
+
+ test('menuItemOrder takes built-in ids and the app’s own, side by side', () => {
+ accept({ menuItemOrder: ['docs', 'createOrganization', 'addAccount', 'signOutAll'] });
+ });
+});
+
+// ─── The profile the button opens ────────────────────────────────────────────
+
+describe('userProfileProps — a navigation entry either has content or goes somewhere', () => {
+ test('a page brings its own content', () => {
+ accept({ userProfileProps: { customPages: [{ label: 'Usage', path: 'usage', content: null }] } });
+ });
+
+ test('a link goes somewhere else', () => {
+ accept({ userProfileProps: { customPages: [{ label: 'Docs', path: 'docs', href: 'https://example.com' }] } });
+ });
+
+ test('an entry cannot be both', () => {
+ // @ts-expect-error — `content` and `href` are mutually exclusive
+ accept({ userProfileProps: { customPages: [{ label: 'X', path: 'x', content: null, href: '/x' }] } });
+ });
+
+ test('an entry needs a path to be ordered by', () => {
+ // @ts-expect-error — `path` identifies the entry, so it is required either way
+ accept({ userProfileProps: { customPages: [{ label: 'X', content: null }] } });
+ });
+
+ test('pageOrder takes built-in page ids and custom paths, side by side', () => {
+ accept({ userProfileProps: { pageOrder: ['account', 'usage', 'security', 'billing', 'apiKeys'] } });
+ });
+});
+
+// ─── Trigger ─────────────────────────────────────────────────────────────────
+
+describe('trigger flags', () => {
+ test('both are booleans', () => {
+ accept({ renderTriggerLabel: false, renderPlanBadge: false });
+ });
+
+ test('a truthy value of another type is rejected', () => {
+ // @ts-expect-error — `renderTriggerLabel` is a boolean, not a label
+ accept({ renderTriggerLabel: 'Acme' });
+ });
+});
diff --git a/packages/ui/src/mosaic/user-button/user-button.tsx b/packages/ui/src/mosaic/user-button/user-button.tsx
index 77ba5d4d96f..749f34190b1 100644
--- a/packages/ui/src/mosaic/user-button/user-button.tsx
+++ b/packages/ui/src/mosaic/user-button/user-button.tsx
@@ -3,23 +3,41 @@
import type { ReactElement } from 'react';
import { useState } from 'react';
+import type { CustomProfileItem } from '../hooks/useCustomPages';
+import { useCustomPages } from '../hooks/useCustomPages';
import { useMosaicEnvironment } from '../hooks/useMosaicEnvironment';
import { useSpinDelay } from '../hooks/useSpinDelay';
+import type { UserProfilePageId } from '../hooks/useUserProfilePages';
+import { useUserProfilePages } from '../hooks/useUserProfilePages';
import type { UserButtonController, UserButtonControllerOptions } from './user-button.controller';
import { useUserButtonController } from './user-button.controller';
import type { UserButtonMenuProps, UserButtonModeProps } from './user-button.types';
import type { UserButtonTriggerProps } from './user-button.view';
import { userButtonBusyKeys, UserButtonView } from './user-button.view';
+/** Configures the UserProfile this button opens. */
+export interface UserButtonUserProfileProps {
+ /** Pages and links of your own, added to the profile's navigation. */
+ customPages?: CustomProfileItem[];
+ /**
+ * The order the profile's navigation runs in, by id: a built-in page's id, or a custom entry's
+ * `path`. Anything left out follows the pages named here. The first page is the one the profile
+ * opens on, so it cannot be a link.
+ */
+ pageOrder?: (UserProfilePageId | (string & {}))[];
+}
+
/**
* Everything `` takes: where its profile surfaces open (`UserButtonControllerOptions`),
- * what the trigger shows (`UserButtonTriggerProps`), and the app's own rows at the foot of the menu
- * (`UserButtonMenuProps`).
+ * what the trigger shows (`UserButtonTriggerProps`), the app's own rows at the foot of the menu
+ * (`UserButtonMenuProps`), and the profile it opens (`UserButtonUserProfileProps`).
*/
export type UserButtonProps = UserButtonControllerOptions &
UserButtonTriggerProps &
UserButtonMenuProps &
- UserButtonModeProps;
+ UserButtonModeProps & {
+ userProfileProps?: UserButtonUserProfileProps;
+ };
/** The one action in flight: which affordance owns it, and what the surface froze on to run it. */
interface PendingAction {
@@ -38,7 +56,7 @@ interface PendingAction {
*
* @example
* ```tsx
- * import { UserButton } from '@clerk/ui/mosaic';
+ * import { UserButton } from '@clerk/nextjs/experimental/mosaic';
*
*
* ```
@@ -65,10 +83,14 @@ interface PendingAction {
* ```
*
* @example
- * `customMenuItems` adds your own rows to the foot of the menu, each one either an `onClick` action
- * or an `href` link, and `menuItemOrder` names the order the foot's rows run in.
+ * `customPages` adds your own pages to the profile this button opens; `customMenuItems` adds your
+ * own rows to the foot of the menu, each one either an `onClick` action or an `href` link.
* ```tsx
* , content: }],
+ * pageOrder: ['account', 'usage', 'security'],
+ * }}
* customMenuItems={[
* { id: 'docs', label: 'Documentation', icon: , href: 'https://example.com/docs' },
* { id: 'support', label: 'Contact support', icon: , onClick: () => openSupportChat() },
@@ -83,11 +105,21 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
renderPlanBadge,
mode: requestedMode,
modePriority,
+ userProfileProps,
customMenuItems,
menuItemOrder,
...options
} = props;
- const controller = useUserButtonController(options);
+ // The profile opens in clerk-js's own React root, so its custom pages reach it as portals rendered
+ // from here. They have to outlive the popover that opened it, and the button's own data with it,
+ // which is why they hang off the container rather than anything the popover renders.
+ const builtInPages = useUserProfilePages();
+ const { customPages, portals } = useCustomPages({
+ items: userProfileProps?.customPages,
+ order: userProfileProps?.pageOrder,
+ builtInPages,
+ });
+ const controller = useUserButtonController(options, customPages);
const [open, setOpen] = useState(false);
const [action, setAction] = useState(null);
@@ -106,7 +138,7 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
// promised to people who are never going to get one. `` is where an app that knows
// its own nav puts a placeholder.
if (controller.status !== 'ready') {
- return null;
+ return <>{portals}>;
}
const close = () => setOpen(false);
@@ -177,28 +209,31 @@ export function UserButton(props: UserButtonProps = {}): ReactElement | null {
} = action?.snapshot ?? controller;
return (
-
+ <>
+
+ {portals}
+ >
);
}
diff --git a/packages/ui/styles.css.d.ts b/packages/ui/styles.css.d.ts
index 2f6203bca8b..2f6934a024b 100644
--- a/packages/ui/styles.css.d.ts
+++ b/packages/ui/styles.css.d.ts
@@ -1,3 +1,3 @@
-// Type stub so `import '@clerk/ui/styles.css'` type-checks. The StyleX build emits
+// Type stub so `import '@clerk/ui/experimental/mosaic/styles.css'` type-checks. The StyleX build emits
// the real stylesheet to dist-mosaic/styles.css; this side-effect import has no value.
export {};
diff --git a/packages/ui/tsconfig.mosaic.json b/packages/ui/tsconfig.mosaic.json
index 05fde7579f6..516fc111be7 100644
--- a/packages/ui/tsconfig.mosaic.json
+++ b/packages/ui/tsconfig.mosaic.json
@@ -7,8 +7,10 @@
// declaration bundle. Its published `dist/*.d.ts` are re-export barrels that
// rolldown-plugin-dts can't follow when inlining, so building types against source
// (the monorepo default) lets Mosaic components import headless types directly.
+ // `utils` and `hooks` sit at the source root; every other subpath is a primitive.
"@clerk/headless/utils": ["../headless/src/utils/index.ts"],
- "@clerk/headless/*": ["../headless/src/*"],
+ "@clerk/headless/hooks": ["../headless/src/hooks/index.ts"],
+ "@clerk/headless/*": ["../headless/src/primitives/*"],
// Preserve the base config's test-only aliases (extends replaces `paths` wholesale).
"@/core/*": ["../clerk-js/src/core/*"],
"@/*": ["./src/*"],
diff --git a/packages/ui/tsdown.mosaic.config.mts b/packages/ui/tsdown.mosaic.config.mts
index fccff71ba8b..f6c29047e83 100644
--- a/packages/ui/tsdown.mosaic.config.mts
+++ b/packages/ui/tsdown.mosaic.config.mts
@@ -2,17 +2,20 @@ import stylexPlugin from '@stylexjs/rollup-plugin';
import { defineConfig } from 'tsdown';
import { mosaicLightningCssTargets } from './stylex-lightningcss.config.mjs';
-// Isolated Mosaic build: compiles ONLY the StyleX barrel (`src/mosaic/styles`)
-// with the StyleX rollup plugin, emitting transformed ESM + a single static
-// `styles.css` that consumers import. Kept separate from the main tsdown build so
-// the Emotion-based code is untouched and this entry stays Emotion-free.
+// Isolated Mosaic build: compiles `src/mosaic` with the StyleX rollup plugin, emitting transformed
+// ESM + a single static `styles.css` that consumers import. Kept separate from the main tsdown build
+// so the Emotion-based code is untouched and this entry stays Emotion-free.
+//
+// The entry is the narrow public surface, not the `src/mosaic/styles` barrel: the barrel exists to
+// pull every migrated component into the StyleX graph, and pointing the published export at it would
+// make all of them (and the headless primitive types behind them) API.
//
// `useCSSLayers` wraps StyleX's atomic rules in `@layer priorityN` for correct
// intra-StyleX precedence; consumers import the sheet into a layer they control
-// (`@import '@clerk/ui/styles.css' layer(components)`), under which those nest
+// (`@import '@clerk/ui/experimental/mosaic/styles.css' layer(components)`), under which those nest
// cleanly, and override from a later layer.
export default defineConfig({
- entry: ['./src/mosaic/styles/index.ts'],
+ entry: ['./src/mosaic/index.ts'],
outDir: './dist-mosaic',
format: ['esm'],
dts: true,
@@ -22,10 +25,30 @@ export default defineConfig({
minify: false,
// Use the standard React JSX runtime, not Emotion's — the Mosaic build must be Emotion-free.
tsconfig: './tsconfig.mosaic.json',
- // `@clerk/headless` stays external here (the main build inlines it): this entry exists to
- // extract `styles.css`, and only that file is exported from the package — so there is nothing
- // to gain from pulling the primitives and their deps into a bundle nobody imports.
- external: ['react', 'react-dom', '@stylexjs/stylex', /^@clerk\/headless/],
+ // tsdown externalizes everything in `dependencies` by default, which is what we want for
+ // `@clerk/shared`: it carries the Clerk context, so the host's copy has to be the one we read.
+ // The two below have to override that default.
+ //
+ // `@clerk/headless` is a private workspace package. Left external, `@clerk/ui` publishes with a
+ // dependency that does not exist on npm, and installing it 404s. `tsconfig.mosaic.json` already
+ // resolves it to source, so this is the backstop: if a subpath ever escapes those `paths`, the
+ // build fails loudly here instead of silently externalizing an unpublishable package.
+ //
+ // StyleX is compiled away at build time; only the tiny `props` merger survives. Bundling it keeps
+ // it out of consumer trees entirely, so nobody inherits our StyleX version or has to have it.
+ //
+ // Floating UI arrives through the bundled `@clerk/headless` primitives, so leaving it external
+ // would make it the one install this entry still demands, defeating the point: SDKs inline this
+ // bundle so consumers need nothing beyond React and `@clerk/shared`. Its contexts are per-tree,
+ // not global, so a second copy alongside the Emotion UI's is inert.
+ deps: {
+ neverBundle: ['react', 'react-dom'],
+ alwaysBundle: [/^@clerk\/headless/, '@stylexjs/stylex', /^@floating-ui\//],
+ },
+ // The bundle collapses every module into one, so the per-file `'use client'` directives are lost.
+ // Everything here is interactive and hook-driven, so the entry is a client boundary in whole —
+ // without this, importing it from a React Server Component fails.
+ outputOptions: { banner: "'use client';" },
plugins: [
stylexPlugin({
fileName: 'styles.css',
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d071c614880..acca6e4d917 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -876,6 +876,9 @@ importers:
specifier: catalog:repo
version: 2.8.1
devDependencies:
+ '@clerk/ui':
+ specifier: workspace:*
+ version: link:../ui
crypto-es:
specifier: ^2.1.0
version: 2.1.0
@@ -1225,9 +1228,6 @@ importers:
'@solana/wallet-standard':
specifier: catalog:module-manager
version: 1.1.4(@solana/wallet-adapter-base@0.9.27(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10)))(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@6.0.3)(utf-8-validate@5.0.10))(bs58@6.0.0)(react@18.3.1)
- '@stylexjs/stylex':
- specifier: 0.19.0
- version: 0.19.0
'@swc/helpers':
specifier: catalog:repo
version: 0.5.21
@@ -1283,6 +1283,9 @@ importers:
'@stylexjs/rollup-plugin':
specifier: 0.19.0
version: 0.19.0
+ '@stylexjs/stylex':
+ specifier: 0.19.0
+ version: 0.19.0
'@stylexjs/unplugin':
specifier: 0.19.0
version: 0.19.0(unplugin@2.3.11)
@@ -8340,6 +8343,7 @@ packages:
crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+ deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained.
css-mediaquery@0.1.2:
resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
diff --git a/scripts/copy-mosaic-styles.mjs b/scripts/copy-mosaic-styles.mjs
new file mode 100644
index 00000000000..ca1fc61d330
--- /dev/null
+++ b/scripts/copy-mosaic-styles.mjs
@@ -0,0 +1,24 @@
+#!/usr/bin/env node
+
+/**
+ * Copies `@clerk/ui`'s built Mosaic stylesheet into the calling package's dist, so an SDK can
+ * export it under its own name (`@clerk/nextjs/experimental/mosaic/styles.css`).
+ *
+ * Copied rather than re-exported through a path into `node_modules`: pnpm's layout gives no stable
+ * relative path from one package to another's files, so an export pointing there resolves only by
+ * luck of hoisting.
+ *
+ * Usage: node ../../scripts/copy-mosaic-styles.mjs
+ */
+
+import { copyFileSync, mkdirSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { dirname, resolve } from 'node:path';
+
+const dest = resolve(process.cwd(), process.argv[2]);
+const source = createRequire(`${process.cwd()}/`).resolve('@clerk/ui/experimental/mosaic/styles.css');
+
+mkdirSync(dirname(dest), { recursive: true });
+copyFileSync(source, dest);
+
+console.log(`✅ Copied the Mosaic stylesheet to ${process.argv[2]}`);