diff --git a/.changeset/purple-apples-read.md b/.changeset/purple-apples-read.md
new file mode 100644
index 00000000000..110f645d9d2
--- /dev/null
+++ b/.changeset/purple-apples-read.md
@@ -0,0 +1,13 @@
+---
+'@clerk/tanstack-react-start': minor
+'@clerk/chrome-extension': minor
+'@clerk/react-router': minor
+'@clerk/clerk-js': minor
+'@clerk/nextjs': minor
+'@clerk/clerk-react': minor
+'@clerk/remix': minor
+'@clerk/types': minor
+'@clerk/vue': minor
+---
+
+Add new component
diff --git a/.changeset/silver-tools-clean.md b/.changeset/silver-tools-clean.md
new file mode 100644
index 00000000000..8580550fcad
--- /dev/null
+++ b/.changeset/silver-tools-clean.md
@@ -0,0 +1,5 @@
+---
+'@clerk/testing': minor
+---
+
+Add Playwright testing helpers under unstable page-objects: `userAvatar.goTo()`, `userAvatar.waitForMounted()`, and `userAvatar.toBeVisible()` for .
diff --git a/integration/templates/next-app-router/src/app/user-avatar/page.tsx b/integration/templates/next-app-router/src/app/user-avatar/page.tsx
new file mode 100644
index 00000000000..0c7ea73f90b
--- /dev/null
+++ b/integration/templates/next-app-router/src/app/user-avatar/page.tsx
@@ -0,0 +1,9 @@
+import { UserAvatar } from '@clerk/nextjs';
+
+export default function Page() {
+ return (
+
+ Loading user avatar>} />
+
+ );
+}
diff --git a/integration/templates/react-vite/src/main.tsx b/integration/templates/react-vite/src/main.tsx
index 417a0511c73..b337553375d 100644
--- a/integration/templates/react-vite/src/main.tsx
+++ b/integration/templates/react-vite/src/main.tsx
@@ -15,6 +15,7 @@ import UserButtonCustomDynamicLabelsAndCustomPages from './custom-user-button/wi
import UserButtonCustomTrigger from './custom-user-button-trigger';
import UserButtonCustomDynamicItems from './custom-user-button/with-dynamic-items.tsx';
import UserButton from './user-button';
+import UserAvatar from './user-avatar';
import Waitlist from './waitlist';
import OrganizationProfile from './organization-profile';
import OrganizationList from './organization-list';
@@ -68,6 +69,10 @@ const router = createBrowserRouter([
path: '/user-button',
element: ,
},
+ {
+ path: '/user-avatar',
+ element: ,
+ },
{
path: '/protected',
element: ,
diff --git a/integration/templates/react-vite/src/user-avatar/index.tsx b/integration/templates/react-vite/src/user-avatar/index.tsx
new file mode 100644
index 00000000000..d608db004a8
--- /dev/null
+++ b/integration/templates/react-vite/src/user-avatar/index.tsx
@@ -0,0 +1,11 @@
+import { UserAvatar } from '@clerk/clerk-react';
+import React from 'react';
+
+export default function UserAvatarPage() {
+ return (
+
+
UserAvatar
+ Loading user avatar>} />
+
+ );
+}
diff --git a/integration/templates/vue-vite/src/router.ts b/integration/templates/vue-vite/src/router.ts
index 31fc822e18e..6fd11280ae6 100644
--- a/integration/templates/vue-vite/src/router.ts
+++ b/integration/templates/vue-vite/src/router.ts
@@ -41,6 +41,11 @@ const routes = [
path: '/pricing-table',
component: () => import('./views/PricingTable.vue'),
},
+ {
+ name: 'UserAvatar',
+ path: '/user-avatar',
+ component: () => import('./views/UserAvatar.vue'),
+ },
// This was added for billing tests
{
name: 'User',
@@ -72,7 +77,7 @@ const router = createRouter({
router.beforeEach(async (to, _, next) => {
const { isSignedIn, isLoaded } = useAuth();
- const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile'];
+ const authenticatedPages = ['Profile', 'Admin', 'CustomUserProfile', 'CustomOrganizationProfile', 'UserAvatar'];
if (!isLoaded.value) {
await waitForClerkJsLoaded(isLoaded);
diff --git a/integration/templates/vue-vite/src/views/UserAvatar.vue b/integration/templates/vue-vite/src/views/UserAvatar.vue
new file mode 100644
index 00000000000..35d46c4f5d7
--- /dev/null
+++ b/integration/templates/vue-vite/src/views/UserAvatar.vue
@@ -0,0 +1,10 @@
+
+
+
+
+
UserAvatar
+
+
+
diff --git a/integration/tests/components.test.ts b/integration/tests/components.test.ts
index 5a36004a92d..bfcd75c210f 100644
--- a/integration/tests/components.test.ts
+++ b/integration/tests/components.test.ts
@@ -41,6 +41,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component
protected: true,
fallback: 'Loading user profile',
},
+ {
+ name: 'UserAvatar',
+ path: '/user-avatar',
+ protected: true,
+ fallback: 'Loading user avatar',
+ },
{
name: 'UserButton',
path: '/user-button',
diff --git a/integration/tests/user-avatar.test.ts b/integration/tests/user-avatar.test.ts
new file mode 100644
index 00000000000..af29e01d015
--- /dev/null
+++ b/integration/tests/user-avatar.test.ts
@@ -0,0 +1,48 @@
+import { test } from '@playwright/test';
+
+import { appConfigs } from '../presets';
+import type { FakeUser } from '../testUtils';
+import { createTestUtils, testAgainstRunningApps } from '../testUtils';
+
+testAgainstRunningApps({
+ withEnv: [appConfigs.envs.withEmailCodes],
+ withPattern: ['react.vite.withEmailCodes', 'vue.vite'],
+})('UserAvatar component integration tests @generic', ({ app }) => {
+ test.describe.configure({ mode: 'serial' });
+
+ let fakeUser: FakeUser;
+
+ test.beforeAll(async () => {
+ const u = createTestUtils({ app });
+ fakeUser = u.services.users.createFakeUser({
+ withPhoneNumber: true,
+ withUsername: true,
+ });
+ await u.services.users.createBapiUser(fakeUser);
+ });
+
+ test.afterAll(async () => {
+ await app.teardown();
+ await fakeUser.deleteIfExists();
+ });
+
+ test.afterEach(async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.signOut();
+ await u.page.context().clearCookies();
+ });
+
+ test('UserAvatar loads and renders correctly when user is signed in', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+
+ await u.po.signIn.goTo();
+ await u.po.signIn.signInWithEmailAndInstantPassword({
+ email: fakeUser.email,
+ password: fakeUser.password,
+ });
+ await u.po.expect.toBeSignedIn();
+
+ await u.po.userAvatar.goTo();
+ await u.po.userAvatar.toBeVisible();
+ });
+});
diff --git a/integration/tests/vue/components.test.ts b/integration/tests/vue/components.test.ts
index 070cfbd2d45..c803a6adc6b 100644
--- a/integration/tests/vue/components.test.ts
+++ b/integration/tests/vue/components.test.ts
@@ -38,6 +38,17 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withCustomRoles] })('basic te
await expect(u.page.getByRole('link', { name: /Sign in/i })).toBeVisible();
});
+ test('render UserAvatar component when user completes sign in flow', async ({ page, context }) => {
+ const u = createTestUtils({ app, page, context });
+ await u.page.goToRelative('/sign-in');
+ await u.po.signIn.waitForMounted();
+ await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
+ await u.po.expect.toBeSignedIn();
+
+ await u.po.userAvatar.goTo();
+ await u.po.userAvatar.toBeVisible();
+ });
+
test('render user button component when user completes sign in flow', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap
index 209abfc6d51..43dd3fc5f45 100644
--- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -29,6 +29,7 @@ exports[`public exports should not include a breaking change 1`] = `
"SignUpButton",
"SignedIn",
"SignedOut",
+ "UserAvatar",
"UserButton",
"UserProfile",
"Waitlist",
diff --git a/packages/chrome-extension/src/react/re-exports.ts b/packages/chrome-extension/src/react/re-exports.ts
index a6f82ee02c2..4c4ca13d711 100644
--- a/packages/chrome-extension/src/react/re-exports.ts
+++ b/packages/chrome-extension/src/react/re-exports.ts
@@ -24,6 +24,7 @@ export {
SignUpButton,
SignedIn,
SignedOut,
+ UserAvatar,
UserButton,
UserProfile,
Waitlist,
diff --git a/packages/clerk-js/sandbox/app.ts b/packages/clerk-js/sandbox/app.ts
index 15d45afc722..6132a5f5025 100644
--- a/packages/clerk-js/sandbox/app.ts
+++ b/packages/clerk-js/sandbox/app.ts
@@ -1,5 +1,5 @@
-import type { Clerk as ClerkType } from '../';
import * as l from '../../localizations';
+import type { Clerk as ClerkType } from '../';
const AVAILABLE_LOCALES = Object.keys(l) as (keyof typeof l)[];
@@ -25,6 +25,7 @@ const AVAILABLE_COMPONENTS = [
'clerk', // While not a component, we want to support passing options to the Clerk class.
'signIn',
'signUp',
+ 'userAvatar',
'userButton',
'userProfile',
'createOrganization',
@@ -86,6 +87,7 @@ const componentControls: Record<(typeof AVAILABLE_COMPONENTS)[number], Component
clerk: buildComponentControls('clerk'),
signIn: buildComponentControls('signIn'),
signUp: buildComponentControls('signUp'),
+ userAvatar: buildComponentControls('userAvatar'),
userButton: buildComponentControls('userButton'),
userProfile: buildComponentControls('userProfile'),
createOrganization: buildComponentControls('createOrganization'),
@@ -287,6 +289,9 @@ void (async () => {
'/sign-up': () => {
Clerk.mountSignUp(app, componentControls.signUp.getProps() ?? {});
},
+ '/user-avatar': () => {
+ Clerk.mountUserAvatar(app, componentControls.userAvatar.getProps() ?? {});
+ },
'/user-button': () => {
Clerk.mountUserButton(app, componentControls.userButton.getProps() ?? {});
},
diff --git a/packages/clerk-js/sandbox/template.html b/packages/clerk-js/sandbox/template.html
index d1cc06fadf6..557eb0a7b3e 100644
--- a/packages/clerk-js/sandbox/template.html
+++ b/packages/clerk-js/sandbox/template.html
@@ -73,6 +73,13 @@
>Sign Up
+
+ User Avatar
+
{
+ this.assertComponentsReady(this.#componentControls);
+ const component = 'UserAvatar';
+ void this.#componentControls.ensureMounted({ preloadHint: component }).then(controls =>
+ controls.mountComponent({
+ name: component,
+ appearanceKey: 'userAvatar',
+ node,
+ props,
+ }),
+ );
+
+ this.telemetry?.record(eventPrebuiltComponentMounted(component, props));
+ };
+
+ public unmountUserAvatar = (node: HTMLDivElement): void => {
+ this.assertComponentsReady(this.#componentControls);
+ void this.#componentControls.ensureMounted().then(controls =>
+ controls.unmountComponent({
+ node,
+ }),
+ );
+ };
+
public mountSignUp = (node: HTMLDivElement, props?: SignUpProps): void => {
this.assertComponentsReady(this.#componentControls);
const component = 'SignUp';
diff --git a/packages/clerk-js/src/ui/components/UserAvatar/index.tsx b/packages/clerk-js/src/ui/components/UserAvatar/index.tsx
new file mode 100644
index 00000000000..0894ffca0cb
--- /dev/null
+++ b/packages/clerk-js/src/ui/components/UserAvatar/index.tsx
@@ -0,0 +1,26 @@
+import { useUser } from '@clerk/shared/react/index';
+import type { UserAvatarProps } from '@clerk/types';
+
+import { useUserAvatarContext, withCoreUserGuard } from '@/ui/contexts';
+import { descriptors } from '@/ui/customizables';
+import { UserAvatar as InternalUserAvatar } from '@/ui/elements/UserAvatar';
+import { InternalThemeProvider } from '@/ui/styledSystem';
+
+export const _UserAvatar = (props: UserAvatarProps) => {
+ const ctx = useUserAvatarContext();
+ const { user } = useUser();
+
+ return (
+
+ theme.sizes.$7}
+ />
+
+ );
+};
+
+export const UserAvatar = withCoreUserGuard(_UserAvatar);
diff --git a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
index ccde1a6e924..f82e5425e91 100644
--- a/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
+++ b/packages/clerk-js/src/ui/contexts/ClerkUIComponentsContext.tsx
@@ -21,6 +21,7 @@ import {
SignInContext,
SignUpContext,
SubscriberTypeContext,
+ UserAvatarContext,
UserButtonContext,
UserProfileContext,
UserVerificationContext,
@@ -50,6 +51,8 @@ export function ComponentContextProvider({
{children}
);
+ case 'UserAvatar':
+ return {children};
case 'UserButton':
return (
diff --git a/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts b/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts
new file mode 100644
index 00000000000..d9df16c19ab
--- /dev/null
+++ b/packages/clerk-js/src/ui/contexts/components/UserAvatar.ts
@@ -0,0 +1,20 @@
+import { createContext, useContext } from 'react';
+
+import type { UserAvatarCtx } from '../../types';
+
+export const UserAvatarContext = createContext(null);
+
+export const useUserAvatarContext = () => {
+ const context = useContext(UserAvatarContext);
+
+ if (!context || context.componentName !== 'UserAvatar') {
+ throw new Error('Clerk: useUserAvatarContext called outside UserAvatar.');
+ }
+
+ const { componentName, ...ctx } = context;
+
+ return {
+ ...ctx,
+ componentName,
+ };
+};
diff --git a/packages/clerk-js/src/ui/contexts/components/index.ts b/packages/clerk-js/src/ui/contexts/components/index.ts
index 408688463e8..3afdda296bf 100644
--- a/packages/clerk-js/src/ui/contexts/components/index.ts
+++ b/packages/clerk-js/src/ui/contexts/components/index.ts
@@ -1,18 +1,20 @@
+export * from './ApiKeys';
+export * from './Checkout';
+export * from './CreateOrganization';
+export * from './GoogleOneTap';
+export * from './OAuthConsent';
+export * from './OrganizationList';
+export * from './OrganizationProfile';
+export * from './OrganizationSwitcher';
+export * from './Plans';
+export * from './PricingTable';
export * from './SignIn';
-export * from './SignUp';
export * from './SignOut';
+export * from './SignUp';
export * from './SubscriberType';
+export * from './SubscriptionDetails';
+export * from './UserAvatar';
+export * from './UserButton';
export * from './UserProfile';
export * from './UserVerification';
-export * from './UserButton';
-export * from './OrganizationSwitcher';
-export * from './OrganizationList';
-export * from './OrganizationProfile';
-export * from './CreateOrganization';
-export * from './GoogleOneTap';
export * from './Waitlist';
-export * from './PricingTable';
-export * from './Checkout';
-export * from './Plans';
-export * from './ApiKeys';
-export * from './OAuthConsent';
diff --git a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
index 1a6da88c12d..2500d89ccf9 100644
--- a/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
+++ b/packages/clerk-js/src/ui/customizables/elementDescriptors.ts
@@ -219,6 +219,9 @@ export const APPEARANCE_KEYS = containsAllElementsConfigKeys([
'taskChooseOrganizationCreateOrganizationActionButton',
'taskChooseOrganizationPreviewButton',
+ 'userAvatarBox',
+ 'userAvatarImage',
+
'userPreview',
'userPreviewAvatarContainer',
'userPreviewAvatarBox',
diff --git a/packages/clerk-js/src/ui/lazyModules/components.ts b/packages/clerk-js/src/ui/lazyModules/components.ts
index c137093cc40..727627e16dc 100644
--- a/packages/clerk-js/src/ui/lazyModules/components.ts
+++ b/packages/clerk-js/src/ui/lazyModules/components.ts
@@ -3,6 +3,7 @@ import { lazy } from 'react';
const componentImportPaths = {
SignIn: () => import(/* webpackChunkName: "signin" */ './../components/SignIn'),
SignUp: () => import(/* webpackChunkName: "signup" */ './../components/SignUp'),
+ UserAvatar: () => import(/* webpackChunkName: "useravatar" */ './../components/UserAvatar'),
UserButton: () => import(/* webpackChunkName: "userbutton" */ './../components/UserButton'),
UserProfile: () => import(/* webpackChunkName: "userprofile" */ './../components/UserProfile'),
CreateOrganization: () => import(/* webpackChunkName: "createorganization" */ './../components/CreateOrganization'),
@@ -47,15 +48,22 @@ export const SignUp = lazy(() => componentImportPaths.SignUp().then(module => ({
export const SignUpModal = lazy(() => componentImportPaths.SignUp().then(module => ({ default: module.SignUpModal })));
+export const UserAvatar = lazy(() =>
+ componentImportPaths.UserAvatar().then(module => ({ default: module.UserAvatar })),
+);
+
export const UserButton = lazy(() =>
componentImportPaths.UserButton().then(module => ({ default: module.UserButton })),
);
+
export const UserProfile = lazy(() =>
componentImportPaths.UserProfile().then(module => ({ default: module.UserProfile })),
);
+
export const UserProfileModal = lazy(() =>
componentImportPaths.UserProfile().then(module => ({ default: module.UserProfileModal })),
);
+
export const CreateOrganization = lazy(() =>
componentImportPaths.CreateOrganization().then(module => ({ default: module.CreateOrganization })),
);
@@ -132,6 +140,7 @@ export const preloadComponent = async (component: unknown) => {
export const ClerkComponents = {
SignIn,
SignUp,
+ UserAvatar,
UserButton,
UserProfile,
UserVerification,
diff --git a/packages/clerk-js/src/ui/types.ts b/packages/clerk-js/src/ui/types.ts
index ec0640fdc1f..f4cb6238221 100644
--- a/packages/clerk-js/src/ui/types.ts
+++ b/packages/clerk-js/src/ui/types.ts
@@ -20,6 +20,7 @@ import type {
SignUpForceRedirectUrl,
SignUpProps,
TaskChooseOrganizationProps,
+ UserAvatarProps,
UserButtonProps,
UserProfileProps,
WaitlistProps,
@@ -35,6 +36,7 @@ export type {
OrganizationSwitcherProps,
SignInProps,
SignUpProps,
+ UserAvatarProps,
UserButtonProps,
UserProfileProps,
WaitlistProps,
@@ -43,6 +45,7 @@ export type {
export type AvailableComponentProps =
| SignInProps
| SignUpProps
+ | UserAvatarProps
| UserProfileProps
| UserButtonProps
| OrganizationSwitcherProps
@@ -90,6 +93,10 @@ export type UserButtonCtx = UserButtonProps & {
mode?: ComponentMode;
};
+export type UserAvatarCtx = UserAvatarProps & {
+ componentName: 'UserAvatar';
+};
+
export type OrganizationProfileCtx = OrganizationProfileProps & {
componentName: 'OrganizationProfile';
mode?: ComponentMode;
@@ -159,6 +166,7 @@ export type PlanDetailsCtx = __internal_PlanDetailsProps & {
export type AvailableComponentCtx =
| SignInCtx
| SignUpCtx
+ | UserAvatarCtx
| UserButtonCtx
| UserProfileCtx
| UserVerificationCtx
diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx
index 446d428365b..a38813c8c7c 100644
--- a/packages/nextjs/src/client-boundary/uiComponents.tsx
+++ b/packages/nextjs/src/client-boundary/uiComponents.tsx
@@ -23,6 +23,7 @@ export {
SignOutButton,
SignUpButton,
TaskChooseOrganization,
+ UserAvatar,
UserButton,
Waitlist,
} from '@clerk/clerk-react';
diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts
index b776e0751e6..2e29bcd7568 100644
--- a/packages/nextjs/src/index.ts
+++ b/packages/nextjs/src/index.ts
@@ -35,6 +35,7 @@ export {
SignUp,
SignUpButton,
TaskChooseOrganization,
+ UserAvatar,
UserButton,
UserProfile,
Waitlist,
diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
index 440ab515d9f..84aae121b9f 100644
--- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -31,6 +31,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"SignedIn",
"SignedOut",
"TaskChooseOrganization",
+ "UserAvatar",
"UserButton",
"UserProfile",
"Waitlist",
diff --git a/packages/react/src/components/__tests__/UserAvatar.test.tsx b/packages/react/src/components/__tests__/UserAvatar.test.tsx
new file mode 100644
index 00000000000..952d4c3bc7b
--- /dev/null
+++ b/packages/react/src/components/__tests__/UserAvatar.test.tsx
@@ -0,0 +1,15 @@
+import type React from 'react';
+import { describe, expectTypeOf, test } from 'vitest';
+
+import type { UserAvatar } from '..';
+
+export type UserAvatarComponentProps = React.ComponentProps;
+
+describe('', () => {
+ describe('Type tests', () => {
+ test('rounded is a boolean', () => {
+ expectTypeOf({ rounded: true }).toMatchTypeOf();
+ expectTypeOf<{ rounded: false }>().toMatchTypeOf();
+ });
+ });
+});
diff --git a/packages/react/src/components/index.ts b/packages/react/src/components/index.ts
index d5a6ca33492..cbf9b77aba1 100644
--- a/packages/react/src/components/index.ts
+++ b/packages/react/src/components/index.ts
@@ -9,6 +9,7 @@ export {
SignIn,
SignUp,
TaskChooseOrganization,
+ UserAvatar,
UserButton,
UserProfile,
Waitlist,
diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx
index 08e5034f038..df694448d01 100644
--- a/packages/react/src/components/uiComponents.tsx
+++ b/packages/react/src/components/uiComponents.tsx
@@ -10,6 +10,7 @@ import type {
SignInProps,
SignUpProps,
TaskChooseOrganizationProps,
+ UserAvatarProps,
UserButtonProps,
UserProfileProps,
WaitlistProps,
@@ -640,6 +641,34 @@ export const APIKeys = withClerk(
{ component: 'ApiKeys', renderWhileLoading: true },
);
+export const UserAvatar = withClerk(
+ ({ clerk, component, fallback, ...props }: WithClerkProp) => {
+ const mountingStatus = useWaitForComponentMount(component);
+ const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded;
+
+ const rendererRootProps = {
+ ...(shouldShowFallback && fallback && { style: { display: 'none' } }),
+ };
+
+ return (
+ <>
+ {shouldShowFallback && fallback}
+ {clerk.loaded && (
+
+ )}
+ >
+ );
+ },
+ { component: 'UserAvatar', renderWhileLoading: true },
+);
+
export const TaskChooseOrganization = withClerk(
({ clerk, component, fallback, ...props }: WithClerkProp) => {
const mountingStatus = useWaitForComponentMount(component);
diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts
index a53fd1d2984..9c2f42cac44 100644
--- a/packages/react/src/isomorphicClerk.ts
+++ b/packages/react/src/isomorphicClerk.ts
@@ -48,6 +48,7 @@ import type {
TaskChooseOrganizationProps,
TasksRedirectOptions,
UnsubscribeCallback,
+ UserAvatarProps,
UserButtonProps,
UserProfileProps,
WaitlistProps,
@@ -132,6 +133,7 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
private preOpenWaitlist?: null | WaitlistProps = null;
private premountSignInNodes = new Map();
private premountSignUpNodes = new Map();
+ private premountUserAvatarNodes = new Map();
private premountUserProfileNodes = new Map();
private premountUserButtonNodes = new Map();
private premountOrganizationProfileNodes = new Map();
@@ -621,6 +623,10 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
clerkjs.mountUserProfile(node, props);
});
+ this.premountUserAvatarNodes.forEach((props, node) => {
+ clerkjs.mountUserAvatar(node, props);
+ });
+
this.premountUserButtonNodes.forEach((props, node) => {
clerkjs.mountUserButton(node, props);
});
@@ -973,6 +979,22 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk {
}
};
+ mountUserAvatar = (node: HTMLDivElement, props?: UserAvatarProps) => {
+ if (this.clerkjs && this.loaded) {
+ this.clerkjs.mountUserAvatar(node, props);
+ } else {
+ this.premountUserAvatarNodes.set(node, props);
+ }
+ };
+
+ unmountUserAvatar = (node: HTMLDivElement) => {
+ if (this.clerkjs && this.loaded) {
+ this.clerkjs.unmountUserAvatar(node);
+ } else {
+ this.premountUserAvatarNodes.delete(node);
+ }
+ };
+
mountUserProfile = (node: HTMLDivElement, props?: UserProfileProps) => {
if (this.clerkjs && this.loaded) {
this.clerkjs.mountUserProfile(node, props);
diff --git a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap
index f985eae730e..7be84469806 100644
--- a/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/remix/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -32,6 +32,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"SignedIn",
"SignedOut",
"TaskChooseOrganization",
+ "UserAvatar",
"UserButton",
"UserProfile",
"Waitlist",
diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
index 28973325dad..f49dd011405 100644
--- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
+++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap
@@ -43,6 +43,7 @@ exports[`root public exports > should not change unexpectedly 1`] = `
"SignedIn",
"SignedOut",
"TaskChooseOrganization",
+ "UserAvatar",
"UserButton",
"UserProfile",
"Waitlist",
diff --git a/packages/testing/src/playwright/unstable/page-objects/index.ts b/packages/testing/src/playwright/unstable/page-objects/index.ts
index 698a91b5677..01c5836e8ad 100644
--- a/packages/testing/src/playwright/unstable/page-objects/index.ts
+++ b/packages/testing/src/playwright/unstable/page-objects/index.ts
@@ -15,6 +15,7 @@ import { createSignInComponentPageObject } from './signIn';
import { createSignUpComponentPageObject } from './signUp';
import { createSubscriptionDetailsPageObject } from './subscriptionDetails';
import { createTestingTokenPageObject } from './testingToken';
+import { createUserAvatarPageObject } from './userAvatar';
import { createUserButtonPageObject } from './userButton';
import { createUserProfileComponentPageObject } from './userProfile';
import { createUserVerificationComponentPageObject } from './userVerification';
@@ -45,6 +46,7 @@ export const createPageObjects = ({
signIn: createSignInComponentPageObject(testArgs),
signUp: createSignUpComponentPageObject(testArgs),
testingToken: createTestingTokenPageObject(testArgs),
+ userAvatar: createUserAvatarPageObject(testArgs),
userButton: createUserButtonPageObject(testArgs),
userProfile: createUserProfileComponentPageObject(testArgs),
userVerification: createUserVerificationComponentPageObject(testArgs),
diff --git a/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts b/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts
new file mode 100644
index 00000000000..3c5ea32efb3
--- /dev/null
+++ b/packages/testing/src/playwright/unstable/page-objects/userAvatar.ts
@@ -0,0 +1,24 @@
+import { expect } from '@playwright/test';
+
+import type { EnhancedPage } from './app';
+
+const SELECTOR = '.cl-userAvatarBox';
+
+export const createUserAvatarPageObject = (testArgs: { page: EnhancedPage }) => {
+ const { page } = testArgs;
+
+ const self = {
+ goTo: async (opts?: { searchParams: URLSearchParams }) => {
+ await page.goToRelative('/user-avatar', opts);
+ return self.waitForMounted();
+ },
+ waitForMounted: (selector = SELECTOR) => {
+ return page.waitForSelector(selector, { state: 'attached' });
+ },
+ toBeVisible: async (selector = SELECTOR) => {
+ return await expect(page.locator(selector).getByRole('img')).toBeVisible();
+ },
+ };
+
+ return self;
+};
diff --git a/packages/types/src/appearance.ts b/packages/types/src/appearance.ts
index 5ef794b80d1..a3d053f67f8 100644
--- a/packages/types/src/appearance.ts
+++ b/packages/types/src/appearance.ts
@@ -358,6 +358,9 @@ export type ElementsConfig = {
taskChooseOrganizationCreateOrganizationActionButton: WithOptions;
taskChooseOrganizationPreviewButton: WithOptions;
+ userAvatarBox: WithOptions;
+ userAvatarImage: WithOptions;
+
// TODO: Test this idea. Instead of userButtonUserPreview, have a userPreview__userButton instead
// Same for other repeated selectors, eg avatar
userPreview: WithOptions;
@@ -967,6 +970,7 @@ export type CaptchaAppearanceOptions = {
export type SignInTheme = Theme;
export type SignUpTheme = Theme;
export type UserButtonTheme = Theme;
+export type UserAvatarTheme = Theme;
export type UserProfileTheme = Theme;
export type OrganizationSwitcherTheme = Theme;
export type OrganizationListTheme = Theme;
@@ -1001,6 +1005,10 @@ export type Appearance = T &
* Theme overrides that only apply to the `` component
*/
signUp?: T;
+ /**
+ * Theme overrides that only apply to the `` component
+ */
+ userAvatar?: T;
/**
* Theme overrides that only apply to the `` component
*/
diff --git a/packages/types/src/clerk.ts b/packages/types/src/clerk.ts
index 8730bea7d3f..d84eb1061e5 100644
--- a/packages/types/src/clerk.ts
+++ b/packages/types/src/clerk.ts
@@ -15,6 +15,7 @@ import type {
SignUpTheme,
SubscriptionDetailsTheme,
TaskChooseOrganizationTheme,
+ UserAvatarTheme,
UserButtonTheme,
UserProfileTheme,
UserVerificationTheme,
@@ -407,6 +408,21 @@ export interface Clerk {
*/
unmountSignUp: (targetNode: HTMLDivElement) => void;
+ /**
+ * Mount a user avatar component at the target element.
+ *
+ * @param targetNode Target node to mount the UserAvatar component.
+ */
+ mountUserAvatar: (targetNode: HTMLDivElement, userAvatarProps?: UserAvatarProps) => void;
+
+ /**
+ * Unmount a user avatar component at the target element.
+ * If there is no component mounted at the target node, results in a noop.
+ *
+ * @param targetNode Target node to unmount the UserAvatar component from.
+ */
+ unmountUserAvatar: (targetNode: HTMLDivElement) => void;
+
/**
* Mount a user button component at the target element.
*
@@ -1623,6 +1639,11 @@ export type UserButtonProps = UserButtonProfileMode & {
customMenuItems?: CustomMenuItem[];
};
+export type UserAvatarProps = {
+ appearance?: UserAvatarTheme;
+ rounded?: boolean;
+};
+
type PrimitiveKeys = {
[K in keyof T]: T[K] extends string | boolean | number | null ? K : never;
}[keyof T];
diff --git a/packages/vue/src/components/index.ts b/packages/vue/src/components/index.ts
index 37827c8ad8a..94af70f0697 100644
--- a/packages/vue/src/components/index.ts
+++ b/packages/vue/src/components/index.ts
@@ -8,6 +8,7 @@ export { default as PricingTable } from './ui-components/PricingTable.vue';
export { UserProfile } from './ui-components/UserProfile';
export { OrganizationProfile } from './ui-components/OrganizationProfile';
export { OrganizationSwitcher } from './ui-components/OrganizationSwitcher';
+export { default as UserAvatar } from './ui-components/UserAvatar.vue';
export { UserButton } from './ui-components/UserButton';
export {
ClerkLoaded,
diff --git a/packages/vue/src/components/ui-components/UserAvatar.vue b/packages/vue/src/components/ui-components/UserAvatar.vue
new file mode 100644
index 00000000000..c03a39dab51
--- /dev/null
+++ b/packages/vue/src/components/ui-components/UserAvatar.vue
@@ -0,0 +1,17 @@
+
+
+
+
+