diff --git a/.changeset/mosaic-contact-list-flip.md b/.changeset/mosaic-contact-list-flip.md
new file mode 100644
index 00000000000..c554feb68be
--- /dev/null
+++ b/.changeset/mosaic-contact-list-flip.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+The Mosaic `UserProfile` email and phone number lists now show the primary entry first, and a newly set primary slides to the top of the list instead of jumping. The slide is skipped under `prefers-reduced-motion`.
diff --git a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
index 96b6cff6372..b4d284c2ebd 100644
--- a/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
+++ b/packages/ui/src/mosaic/features/user-profile/__tests__/user-profile-contact-list-row.view.test.tsx
@@ -5,6 +5,23 @@ import { describe, expect, it, vi } from 'vitest';
import { UserProfileContactListRowView } from '../user-profile-account-section/user-profile-contact-list-row.view';
describe('UserProfileContactListRowView', () => {
+ it('lists the primary item first, keeping the rest in the given order', () => {
+ render(
+ ,
+ );
+
+ const values = screen.getAllByText(/^(First|Second|Third)$/).map(element => element.textContent);
+ expect(values).toEqual(['Second', 'First', 'Third']);
+ });
+
it.each(['email', 'phone'] as const)('hides the menu when no %s action applies', kind => {
const onVerify = vi.fn();
const onSetPrimary = vi.fn();
diff --git a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx
index ae602a53a95..ac448048c7f 100644
--- a/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx
+++ b/packages/ui/src/mosaic/features/user-profile/user-profile-account-section/user-profile-contact-list-row.view.tsx
@@ -4,6 +4,7 @@ import { Badge } from '../../../components/badge';
import { Button } from '../../../components/button';
import { Icon } from '../../../components/icon';
import { Section } from '../../../components/section';
+import { useFlipReorder } from '../../../hooks/useFlipReorder';
import type { UserProfileMenuAction } from '../user-profile-action-menu';
import { UserProfileActionMenu } from '../user-profile-action-menu';
import { styles } from '../user-profile-profile-panel.styles';
@@ -33,6 +34,8 @@ export function UserProfileContactListRowView({
renderActionDialog,
}: UserProfileContactListRowViewProps) {
const emptyDescription = m[kind].empty;
+ const orderedItems = [...items.filter(item => item.isDefault), ...items.filter(item => !item.isDefault)];
+ const itemsRef = useFlipReorder(orderedItems.map(item => item.id));
return (
@@ -61,7 +64,7 @@ export function UserProfileContactListRowView({
) : null}
-
+
{items.length === 0 ? (
@@ -69,7 +72,7 @@ export function UserProfileContactListRowView({
) : (
- items.map(item => {
+ orderedItems.map(item => {
const actions: UserProfileMenuAction[] = [];
if (item.isVerified === false && onVerify) {
diff --git a/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx b/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx
new file mode 100644
index 00000000000..2f9606676b0
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/__tests__/useFlipReorder.test.tsx
@@ -0,0 +1,125 @@
+import { render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { durationVars, easingVars } from '../../tokens.stylex';
+import { useFlipReorder } from '../useFlipReorder';
+
+const ROW_HEIGHT = 40;
+const EASING = 'cubic-bezier(0.1, 0.2, 0.3, 1)';
+
+function List({ order }: { order: string[] }) {
+ const ref = useFlipReorder(order);
+ return (
+
+ {order.map(id => (
+ -
+ {id}
+
+ ))}
+
+ );
+}
+
+function customPropertyName(reference: string) {
+ const name = reference.match(/^var\((--[\w-]+)\)$/)?.[1];
+ if (!name) {
+ throw new Error(`Not a token reference: ${reference}`);
+ }
+ return name;
+}
+
+function defineTokens(element: HTMLElement) {
+ element.style.setProperty(customPropertyName(durationVars['--cl-duration-slow']), '0.25s');
+ element.style.setProperty(customPropertyName(easingVars['--cl-ease-enter']), EASING);
+}
+
+describe('useFlipReorder', () => {
+ const originalRect = Element.prototype.getBoundingClientRect;
+ const originalMatchMedia = window.matchMedia;
+ let animate: ReturnType;
+
+ beforeEach(() => {
+ animate = vi.spyOn(HTMLElement.prototype, 'animate');
+ Element.prototype.getBoundingClientRect = function () {
+ const index = this.parentElement ? Array.from(this.parentElement.children).indexOf(this) : 0;
+ const top = index * ROW_HEIGHT;
+ return {
+ top,
+ left: 0,
+ bottom: top + ROW_HEIGHT,
+ right: 100,
+ width: 100,
+ height: ROW_HEIGHT,
+ x: 0,
+ y: top,
+ toJSON: () => ({}),
+ };
+ };
+ });
+
+ afterEach(() => {
+ animate.mockRestore();
+ Element.prototype.getBoundingClientRect = originalRect;
+ window.matchMedia = originalMatchMedia;
+ });
+
+ it('animates moved children from their old position at the Mosaic duration and easing', () => {
+ const { rerender } = render(
);
+ defineTokens(screen.getByTestId('list'));
+ rerender(
);
+
+ expect(animate).toHaveBeenCalledTimes(3);
+ const options = { duration: 250, easing: EASING };
+ expect(animate.mock.instances[0]).toBe(screen.getByTestId('c'));
+ expect(animate).toHaveBeenNthCalledWith(
+ 1,
+ [{ transform: `translate(0px, ${2 * ROW_HEIGHT}px)` }, { transform: 'none' }],
+ options,
+ );
+ expect(animate.mock.instances[1]).toBe(screen.getByTestId('a'));
+ expect(animate).toHaveBeenNthCalledWith(
+ 2,
+ [{ transform: `translate(0px, ${-ROW_HEIGHT}px)` }, { transform: 'none' }],
+ options,
+ );
+ expect(animate.mock.instances[2]).toBe(screen.getByTestId('b'));
+ });
+
+ it('leaves children alone when the order is unchanged or they are new', () => {
+ const { rerender } = render(
);
+ defineTokens(screen.getByTestId('list'));
+ rerender(
);
+ rerender(
);
+
+ expect(animate).not.toHaveBeenCalled();
+ });
+
+ it('does not animate under prefers-reduced-motion', () => {
+ window.matchMedia = vi.fn().mockImplementation((query: string) => ({
+ matches: query === '(prefers-reduced-motion: reduce)',
+ media: query,
+ onchange: null,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ }));
+ const { rerender } = render(
);
+ defineTokens(screen.getByTestId('list'));
+ rerender(
);
+
+ expect(animate).not.toHaveBeenCalled();
+ });
+
+ it('does not animate when the motion tokens are not defined', () => {
+ const { rerender } = render(
);
+ rerender(
);
+
+ expect(animate).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/mosaic/hooks/useFlipReorder.ts b/packages/ui/src/mosaic/hooks/useFlipReorder.ts
new file mode 100644
index 00000000000..fccb8d9c4d2
--- /dev/null
+++ b/packages/ui/src/mosaic/hooks/useFlipReorder.ts
@@ -0,0 +1,93 @@
+import { useSafeLayoutEffect } from '@clerk/shared/react';
+import { useRef } from 'react';
+
+import { durationVars, easingVars } from '../tokens.stylex';
+
+const DURATION = durationVars['--cl-duration-slow'];
+const EASING = easingVars['--cl-ease-enter'];
+
+function measureChildren(container: HTMLElement | null) {
+ const rects = new Map();
+ if (container) {
+ for (const child of Array.from(container.children)) {
+ rects.set(child, child.getBoundingClientRect());
+ }
+ }
+ return rects;
+}
+
+function readToken(element: Element, reference: string) {
+ const name = reference.match(/^var\((--[\w-]+)\)$/)?.[1];
+ return name ? getComputedStyle(element).getPropertyValue(name).trim() : '';
+}
+
+function toMilliseconds(time: string) {
+ if (time.endsWith('ms')) {
+ return Number.parseFloat(time);
+ }
+ if (time.endsWith('s')) {
+ return Number.parseFloat(time) * 1000;
+ }
+ return Number.NaN;
+}
+
+/**
+ * Slides a container's children from their old position to their new one whenever `order` changes
+ * (a FLIP animation). Attach the returned ref to the container. Keyed children keep their DOM
+ * nodes across a reorder, so each node's rect is read before the commit and again after it, and the
+ * difference plays as a Web Animations API `transform` animation at the Mosaic `slow` duration and
+ * `enter` easing, read from the container's computed style. Skipped under `prefers-reduced-motion`.
+ */
+export function useFlipReorder(order: readonly string[]) {
+ const ref = useRef(null);
+ const key = JSON.stringify(order);
+ const renderedKey = useRef(key);
+ const before = useRef