diff --git a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx b/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx
deleted file mode 100644
index 1d1b6f9cafd..00000000000
--- a/packages/ui/src/mosaic/user-profile/user-profile-sidebar.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from 'react';
-
-import type { IconName } from '../icons/registry';
-import type { ProfilePageSidebarProps } from '../profile-page';
-import { ProfilePage } from '../profile-page';
-
-export type UserProfilePanelId = 'account' | 'security' | 'billing' | 'api-keys';
-
-const destinations: Record
= {
- account: { label: 'Account', icon: 'user-circle' },
- security: { label: 'Security', icon: 'shield-check' },
- billing: { label: 'Billing', icon: 'credit-card' },
- 'api-keys': { label: 'API Keys', icon: 'code' },
-};
-
-export interface UserProfileSidebarProps extends Omit {
- panels: readonly UserProfilePanelId[];
- renderBranding?: boolean;
-}
-
-export const UserProfileSidebar = React.forwardRef(function UserProfileSidebar(
- { panels, ...rest },
- ref,
-) {
- return (
- ({ value, ...destinations[value] }))}
- navigationLabel='User profile'
- {...rest}
- />
- );
-});
diff --git a/packages/ui/src/mosaic/user-profile/user-profile.layout.ts b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts
new file mode 100644
index 00000000000..26dd74e6ae0
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile.layout.ts
@@ -0,0 +1,40 @@
+import type { IconName } from '../icons/registry';
+import { applyOrder } from '../utils/apply-order';
+import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
+
+/** The built-in pages in the order the profile lists them before a consumer reorders anything. */
+export const USER_PROFILE_PAGE_IDS: readonly UserProfilePageId[] = ['account', 'security', 'billing', 'apiKeys'];
+
+export const USER_PROFILE_PAGE_ICONS: Record = {
+ account: 'user-circle',
+ security: 'shield-check',
+ billing: 'credit-card',
+ apiKeys: 'code',
+};
+
+/** One row of the navigation: a built-in page by id, or a page of the consumer's own. */
+export type UserProfileNavEntry =
+ | { id: UserProfilePageId; custom?: undefined }
+ | { id: string; custom: CustomProfilePage };
+
+/** The built-in pages this instance was given content for. `account` is always among them. */
+export function getAvailableUserProfilePages(pages: UserProfilePages): UserProfilePageId[] {
+ return USER_PROFILE_PAGE_IDS.filter(id => pages[id] !== undefined);
+}
+
+/**
+ * The navigation, in order: the built-ins the instance shows, then the consumer's pages, with
+ * `order` moving any of them by id (a custom page's id is its `path`). Same rule as the
+ * UserButton's menu — see `applyOrder`.
+ */
+export function resolveUserProfilePages(
+ builtIn: readonly UserProfilePageId[],
+ customPages: readonly CustomProfilePage[] = [],
+ order?: readonly string[],
+): UserProfileNavEntry[] {
+ const entries: UserProfileNavEntry[] = [
+ ...builtIn.map(id => ({ id })),
+ ...customPages.map(page => ({ id: page.path, custom: page })),
+ ];
+ return applyOrder(order, entries, entry => entry.id);
+}
diff --git a/packages/ui/src/mosaic/user-profile/user-profile.messages.ts b/packages/ui/src/mosaic/user-profile/user-profile.messages.ts
new file mode 100644
index 00000000000..74edb95e4e0
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile.messages.ts
@@ -0,0 +1,15 @@
+/**
+ * Every string the surface renders. Shaped the way `@clerk/i18n` takes a base definition, so
+ * localizing this component is a matter of registering the namespace and swapping the reads for
+ * `useMessages('userProfile', userProfileBase)`, not of hunting the literals down first.
+ */
+export const userProfileBase = {
+ /** Names the surface: its navigation landmark, and the dialog it opens in. */
+ label: 'User profile',
+ pages: {
+ account: 'Account',
+ security: 'Security',
+ billing: 'Billing',
+ apiKeys: 'API Keys',
+ },
+};
diff --git a/packages/ui/src/mosaic/user-profile/user-profile.types.ts b/packages/ui/src/mosaic/user-profile/user-profile.types.ts
new file mode 100644
index 00000000000..a7d042e6ee5
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile.types.ts
@@ -0,0 +1,43 @@
+import type { ReactNode } from 'react';
+
+import type { UserProfileApiKeysPanelViewProps } from './user-profile-api-keys-panel.view';
+import type { UserProfileBillingPanelViewProps } from './user-profile-billing-panel.view';
+import type { UserProfileProfilePanelViewProps } from './user-profile-profile-panel.view';
+import type { UserProfileSecurityPanelViewProps } from './user-profile-security-panel.view';
+
+/** A page the UserProfile brings itself, named by the id its navigation knows it as. */
+export type UserProfilePageId = 'account' | 'security' | 'billing' | 'apiKeys';
+
+/** The built-in pages an instance shows: `account` always, the rest as the environment allows. */
+export interface UserProfilePages {
+ account: UserProfileProfilePanelViewProps;
+ security?: UserProfileSecurityPanelViewProps;
+ billing?: UserProfileBillingPanelViewProps;
+ apiKeys?: UserProfileApiKeysPanelViewProps;
+}
+
+/** 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;
diff --git a/packages/ui/src/mosaic/user-profile/user-profile.view.tsx b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx
new file mode 100644
index 00000000000..4fb4b5cf2c0
--- /dev/null
+++ b/packages/ui/src/mosaic/user-profile/user-profile.view.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+
+import { Icon } from '../components/icon';
+import type { ProfileRootProps } from '../components/profile';
+import { Profile } from '../components/profile';
+import { getAvailableUserProfilePages, resolveUserProfilePages, USER_PROFILE_PAGE_ICONS } from './user-profile.layout';
+import { userProfileBase as m } from './user-profile.messages';
+import type { CustomProfilePage, UserProfilePageId, UserProfilePages } from './user-profile.types';
+import { UserProfileApiKeysPanelView } from './user-profile-api-keys-panel.view';
+import { UserProfileBillingPanelView } from './user-profile-billing-panel.view';
+import { UserProfileProfilePanelView } from './user-profile-profile-panel.view';
+import { UserProfileSecurityPanelView } from './user-profile-security-panel.view';
+
+export interface UserProfileViewProps extends Omit {
+ /** Names the surface, and the dialog it opens in. Defaults to English; pass a localized string once one is available. */
+ label?: string;
+ /** The open page: a built-in page's id, or a custom page's `path`. */
+ activePage: UserProfilePageId | (string & {});
+ pages: UserProfilePages;
+ /** Pages of the consumer's own, added to the navigation after the built-ins. */
+ customPages?: readonly CustomProfilePage[];
+ /**
+ * The order the navigation runs in, by id: a built-in page's id, or a custom page's `path`. Ids
+ * left out keep their default place behind the ones named.
+ */
+ pageOrder?: readonly (UserProfilePageId | (string & {}))[];
+ onPageChange: (page: UserProfilePageId | (string & {})) => void;
+}
+
+function BuiltInPage({ id, pages }: { id: UserProfilePageId; pages: UserProfilePages }): React.ReactElement | null {
+ switch (id) {
+ case 'account':
+ return ;
+ case 'security':
+ return pages.security ? : null;
+ case 'billing':
+ return pages.billing ? : null;
+ case 'apiKeys':
+ return pages.apiKeys ? : null;
+ }
+}
+
+/**
+ * The user profile as a `Profile`: the built-in pages the instance has content for, the
+ * consumer's own pages after them, in the order asked for. An `activePage` the navigation does
+ * not list falls back to the first one, so a page turned off by the environment cannot leave the
+ * surface blank.
+ */
+export const UserProfileView = React.forwardRef(function UserProfileView(
+ { activePage, pages, customPages, pageOrder, onPageChange, label = m.label, ...rest },
+ ref,
+) {
+ const entries = resolveUserProfilePages(getAvailableUserProfilePages(pages), customPages, pageOrder);
+ const resolvedPage = entries.some(entry => entry.id === activePage) ? activePage : entries[0].id;
+
+ return (
+
+ {label}
+
+ {entries.map(entry => (
+
+ )
+ }
+ >
+ {entry.custom ? entry.custom.label : m.pages[entry.id]}
+
+ ))}
+
+
+ {entries.map(entry => (
+
+ {entry.custom ? (
+ entry.custom.content
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+});
diff --git a/packages/ui/src/mosaic/utils/apply-order.ts b/packages/ui/src/mosaic/utils/apply-order.ts
new file mode 100644
index 00000000000..a5f638ff83d
--- /dev/null
+++ b/packages/ui/src/mosaic/utils/apply-order.ts
@@ -0,0 +1,22 @@
+/**
+ * The one ordering rule every list a consumer can reorder follows: the ids `order` names lead, in
+ * the order it names them, and whatever it leaves out keeps its default place behind them.
+ *
+ * A name matching no item is dropped rather than held open, since which items a surface carries
+ * depends on how it was configured and naming one it has not got is ordinary rather than a mistake.
+ * Two items sharing an id are one item: the first wins, so a consumer's own row shadows the built-in
+ * it was given the name of instead of both answering to it.
+ */
+export function applyOrder(
+ order: readonly string[] | undefined,
+ items: readonly T[],
+ idOf: (item: T) => string,
+): T[] {
+ const unique = items.filter((item, index, all) => all.findIndex(other => idOf(other) === idOf(item)) === index);
+ if (!order?.length) {
+ return unique;
+ }
+
+ const named = [...new Set(order)].flatMap(id => unique.filter(item => idOf(item) === id));
+ return [...named, ...unique.filter(item => !named.includes(item))];
+}