diff --git a/.changeset/popover-enter-exit-animations.md b/.changeset/popover-enter-exit-animations.md
new file mode 100644
index 00000000000..063d77d079f
--- /dev/null
+++ b/.changeset/popover-enter-exit-animations.md
@@ -0,0 +1,5 @@
+---
+'@clerk/ui': patch
+---
+
+Popover-based surfaces now open and close with an origin-aware scale-and-fade that grows from the edge nearest the trigger, replacing the previous entrance-only slide animation. This covers the `` / `` menus, the overflow (⋯) menus, and selects — menus keep a quick-in / soft-out feel while selects use a quicker exit. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion.
diff --git a/integration/tests/custom-pages.test.ts b/integration/tests/custom-pages.test.ts
index 5f7304f615a..46c970eb59a 100644
--- a/integration/tests/custom-pages.test.ts
+++ b/integration/tests/custom-pages.test.ts
@@ -405,14 +405,19 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
const notificationsButton = buttons[2];
const languageButton = buttons[3];
+ // Clicking an action closes the popover, which now plays an exit animation and stays
+ // mounted until it finishes — wait for it to fully close before re-opening, otherwise
+ // the re-open races the still-visible exiting popover.
// Test chat toggle
await chatButton.click();
+ await u.po.userButton.waitForPopoverClosed();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
await expect(chatButton).toHaveText('🌐Chat is ON');
await expect(languageButton).toHaveText('🌍Language: EN');
await notificationsButton.click();
+ await u.po.userButton.waitForPopoverClosed();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
await expect(notificationsButton).toHaveText('🌐Notifications 🔔 ON');
@@ -460,14 +465,19 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
const languageButton = buttons[3];
const manageAccountButton = buttons[4];
+ // Clicking an action closes the popover, which now plays an exit animation and stays
+ // mounted until it finishes — wait for it to fully close before re-opening, otherwise
+ // the re-open races the still-visible exiting popover.
// Test chat toggle
await chatButton.click();
+ await u.po.userButton.waitForPopoverClosed();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
await expect(chatButton).toHaveText('🌐Chat is ON');
await expect(languageButton).toHaveText('🌍Language: EN');
await notificationsButton.click();
+ await u.po.userButton.waitForPopoverClosed();
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
await expect(notificationsButton).toHaveText('🌐Notifications 🔔 ON');
@@ -508,6 +518,10 @@ testAgainstRunningApps({ withPattern: ['react.vite.withEmailCodes'] })(
await expect(toggleButton.locator('span')).toHaveText('🔔');
await toggleButton.click();
+ // The action closes the popover with an exit animation that keeps it mounted until it
+ // finishes; wait for the full close so the re-open doesn't race the exiting popover.
+ await u.po.userButton.waitForPopoverClosed();
+
// Re-open menu to see updated items
await u.po.userButton.toggleTrigger();
await u.po.userButton.waitForPopover();
diff --git a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts
index 589c2a2f80f..dc3aff30aec 100644
--- a/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts
+++ b/packages/testing/src/playwright/unstable/page-objects/apiKeys.ts
@@ -61,7 +61,10 @@ export const createAPIKeysComponentPageObject = (testArgs: { page: EnhancedPage
return page.getByLabel(/Type "Revoke" to confirm/i).fill(value);
},
clickConfirmRevokeButton: () => {
- return page.getByText(/Revoke key/i).click();
+ // Target the modal's submit button by its descriptor class rather than by text: the menu
+ // action that opens this modal is also labelled "Revoke key", and it now lingers through an
+ // exit animation, so a text locator transiently matches both (strict-mode violation).
+ return page.locator('.cl-apiKeysRevokeModal .cl-apiKeysRevokeModalSubmitButton').click();
},
};
return self;
diff --git a/packages/ui/bundlewatch.config.json b/packages/ui/bundlewatch.config.json
index 08f942932d0..c3e10aadcff 100644
--- a/packages/ui/bundlewatch.config.json
+++ b/packages/ui/bundlewatch.config.json
@@ -5,7 +5,7 @@
{ "path": "./dist/ui.shared.browser.js", "maxSize": "40KB" },
{ "path": "./dist/framework*.js", "maxSize": "44KB" },
{ "path": "./dist/vendors*.js", "maxSize": "73KB" },
- { "path": "./dist/ui-common*.js", "maxSize": "130KB" },
+ { "path": "./dist/ui-common*.js", "maxSize": "132KB" },
{ "path": "./dist/signin*.js", "maxSize": "17KB" },
{ "path": "./dist/signup*.js", "maxSize": "13KB" },
{ "path": "./dist/userprofile*.js", "maxSize": "16KB" },
diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
index 617ba54c300..113a196958b 100644
--- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
+++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx
@@ -23,7 +23,6 @@ export const OrganizationSwitcherPopover = React.forwardRef unsafeClose?.(false);
const card = useCardState();
- const { __experimental_asStandalone } = useOrganizationSwitcherContext();
const { openOrganizationProfile, openCreateOrganization } = useClerk();
const getContainer = usePortalRoot();
const { organization: currentOrg } = useOrganization();
@@ -138,7 +137,6 @@ export const OrganizationSwitcherPopover = React.forwardRef
diff --git a/packages/ui/src/components/OrganizationSwitcher/index.tsx b/packages/ui/src/components/OrganizationSwitcher/index.tsx
index fc9b00f915d..8939dab433b 100644
--- a/packages/ui/src/components/OrganizationSwitcher/index.tsx
+++ b/packages/ui/src/components/OrganizationSwitcher/index.tsx
@@ -42,7 +42,7 @@ const OrganizationSwitcherWithFloatingTree = withFloatingTree<{ children: ReactE
diff --git a/packages/ui/src/components/UserButton/UserButtonPopover.tsx b/packages/ui/src/components/UserButton/UserButtonPopover.tsx
index 6eab611bf1f..b0d8dad7dff 100644
--- a/packages/ui/src/components/UserButton/UserButtonPopover.tsx
+++ b/packages/ui/src/components/UserButton/UserButtonPopover.tsx
@@ -19,7 +19,6 @@ export const UserButtonPopover = React.forwardRef unsafeClose?.(false);
const { session } = useSession() as { session: SignedInSessionResource };
const userButtonContext = useUserButtonContext();
- const { __experimental_asStandalone } = userButtonContext;
const { authConfig } = useEnvironment();
const { user } = useUser();
const { t } = useLocalizations();
@@ -40,7 +39,6 @@ export const UserButtonPopover = React.forwardRef
diff --git a/packages/ui/src/components/UserButton/index.tsx b/packages/ui/src/components/UserButton/index.tsx
index ecab5631090..ac5fd2a8c50 100644
--- a/packages/ui/src/components/UserButton/index.tsx
+++ b/packages/ui/src/components/UserButton/index.tsx
@@ -41,7 +41,7 @@ const UserButtonWithFloatingTree = withFloatingTree<{ children: ReactElement }>(
diff --git a/packages/ui/src/elements/Menu.tsx b/packages/ui/src/elements/Menu.tsx
index e9ceed6718c..85ebacae98c 100644
--- a/packages/ui/src/elements/Menu.tsx
+++ b/packages/ui/src/elements/Menu.tsx
@@ -18,7 +18,6 @@ import { Col, descriptors, SimpleButton } from '../customizables';
import type { UsePopoverReturn } from '../hooks';
import { usePopover } from '../hooks';
import type { PropsOfComponent } from '../styledSystem';
-import { animations } from '../styledSystem';
import { colors } from '../utils/colors';
import { withFloatingTree } from './contexts';
import { Popover } from './Popover';
@@ -124,20 +123,24 @@ type MenuListProps = PropsOfComponent & {
export const MenuList = (props: MenuListProps) => {
const { sx, asPortal, ...rest } = props;
const { popoverCtx, elementId, getFloatingProps, elementsRef } = useMenuState();
- const { floating, styles, isOpen, context, nodeId } = popoverCtx;
+ const { floating, styles, context, nodeId } = popoverCtx;
const containerRef = useRef(null);
const mergedRef = useMergeRefs([containerRef, floating]);
+ // `FloatingList` only provides list-navigation context (no DOM), so it wraps the `Popover`
+ // rather than nesting inside it — that keeps the `Col` as the `Popover`'s direct child, which is
+ // the element the `animateExit` transition (origin-aware scale + fade) is applied to.
return (
-
-
+
+
{
padding: t.space.$0x5,
overflow: 'hidden',
top: `calc(100% + ${t.space.$2})`,
- animation: `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$slower} ${t.transitionTiming.$slowBezier}`,
- transformOrigin: 'top center',
zIndex: t.zIndices.$dropdown,
gap: t.space.$0x5,
}),
@@ -164,8 +165,8 @@ export const MenuList = (props: MenuListProps) => {
{...getFloatingProps()}
{...rest}
/>
-
-
+
+
);
};
diff --git a/packages/ui/src/elements/PhoneInput/index.tsx b/packages/ui/src/elements/PhoneInput/index.tsx
index 77db7f71d79..42abca966f5 100644
--- a/packages/ui/src/elements/PhoneInput/index.tsx
+++ b/packages/ui/src/elements/PhoneInput/index.tsx
@@ -145,6 +145,7 @@ const PhoneInputBase = forwardRef
({
gap: 0,
diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx
index 9a2ac5a8e0c..13882e9bfa8 100644
--- a/packages/ui/src/elements/Popover.tsx
+++ b/packages/ui/src/elements/Popover.tsx
@@ -1,14 +1,38 @@
import { usePortalRoot } from '@clerk/shared/react';
import type { FloatingContext, ReferenceType } from '@floating-ui/react';
-import { FloatingFocusManager, FloatingNode, FloatingPortal } from '@floating-ui/react';
+import { FloatingFocusManager, FloatingNode, FloatingPortal, useTransitionStyles } from '@floating-ui/react';
import type { PropsWithChildren } from 'react';
import React from 'react';
+import { useAppearance } from '../customizables';
+import { transitionDurationValues, transitionTiming } from '../foundations/transitions';
+import { useDirection, usePrefersReducedMotion } from '../hooks';
+
type PopoverProps = PropsWithChildren<{
context: FloatingContext;
nodeId?: string;
isOpen?: boolean;
initialFocus?: number | React.MutableRefObject;
+ /**
+ * When `true`, the popover animates in on mount with an origin-aware scale + fade and stays
+ * mounted through a matching fade-out on close, instead of appearing/unmounting instantly.
+ * Opt-in so other consumers keep their current instant behavior.
+ * @default false
+ */
+ animateExit?: boolean;
+ /**
+ * When `animateExit` is set, exits quickly (`$faster`, matching the entrance) instead of the
+ * default soft exit (`$slower`). The entrance is always `$faster`.
+ * @default false
+ */
+ exitFast?: boolean;
+ /**
+ * The scale the popover animates from on enter (and collapses to on exit) when `animateExit`
+ * is set. Scale is proportional, so compact surfaces travel fewer pixels — lower this
+ * (e.g. `0.92`) to give small popovers like menus a more pronounced motion.
+ * @default 0.96
+ */
+ initialScale?: number;
/**
* Determines whether outside elements are inert when modal is enabled. This enables pointer modality without a backdrop.
* @default false
@@ -30,6 +54,27 @@ type PopoverProps = PropsWithChildren<{
root?: HTMLElement | React.MutableRefObject;
}>;
+/**
+ * Maps a resolved Floating UI placement to a `transform-origin` so the popover grows from the
+ * point nearest its trigger. The primary axis anchors to the edge opposite the `side`; when the
+ * placement is aligned to a corner (`-start` / `-end`) the cross axis hugs that corner, otherwise
+ * it stays centered. Horizontal alignment is mirrored under RTL to match Floating UI's
+ * direction-aware resolved position.
+ */
+const getTransformOrigin = (placement: string, direction: 'ltr' | 'rtl'): string => {
+ const [side, alignment] = placement.split('-');
+ if (side === 'top' || side === 'bottom') {
+ const y = side === 'top' ? 'bottom' : 'top';
+ const start = direction === 'rtl' ? 'right' : 'left';
+ const end = direction === 'rtl' ? 'left' : 'right';
+ const x = alignment === 'start' ? start : alignment === 'end' ? end : 'center';
+ return `${x} ${y}`;
+ }
+ const x = side === 'left' ? 'right' : 'left';
+ const y = alignment === 'start' ? 'top' : alignment === 'end' ? 'bottom' : 'center';
+ return `${x} ${y}`;
+};
+
export const Popover = (props: PopoverProps) => {
const {
context,
@@ -39,6 +84,9 @@ export const Popover = (props: PopoverProps) => {
modal = true,
nodeId,
isOpen,
+ animateExit = false,
+ exitFast = false,
+ initialScale = 0.96,
portal = true,
root,
children,
@@ -47,11 +95,45 @@ export const Popover = (props: PopoverProps) => {
const portalRoot = usePortalRoot();
const effectiveRoot = root ?? portalRoot?.() ?? undefined;
+ const prefersReducedMotion = usePrefersReducedMotion();
+ const direction = useDirection();
+ const { animations: layoutAnimations } = useAppearance().parsedOptions;
+ const isMotionSafe = !prefersReducedMotion && layoutAnimations === true;
+ const animate = animateExit && isMotionSafe;
+ const closeDuration = exitFast ? transitionDurationValues.faster : transitionDurationValues.slower;
+
+ // Animate in on mount and out on close with an origin-aware scale + fade. Keeping the element
+ // mounted through the close transition (via `isMounted`) is what makes the exit animation
+ // possible. `transformOrigin` is derived from the resolved placement so the popover grows from
+ // the point nearest its trigger — the aligned corner when present, otherwise the edge center.
+ const { isMounted, styles: transitionStyles } = useTransitionStyles(context, {
+ duration: {
+ open: animate ? transitionDurationValues.faster : 0,
+ close: animate ? closeDuration : 0,
+ },
+ common: ({ placement }) => ({
+ transformOrigin: getTransformOrigin(placement, direction),
+ transitionTimingFunction: transitionTiming.swiftOut,
+ }),
+ initial: { opacity: animate ? 0 : 1, transform: animate ? `scale(${initialScale})` : 'scale(1)' },
+ open: { opacity: 1, transform: 'scale(1)' },
+ close: { opacity: animate ? 0 : 1, transform: animate ? `scale(${initialScale})` : 'scale(1)' },
+ });
+
+ // Non-animating consumers keep the original synchronous `isOpen` unmount.
+ const shouldRender = animateExit ? isMounted : isOpen;
+ const content =
+ animateExit && React.isValidElement<{ style?: React.CSSProperties }>(children)
+ ? React.cloneElement(children, {
+ style: { ...children.props.style, ...transitionStyles },
+ })
+ : children;
+
if (portal) {
return (
- {isOpen && (
+ {shouldRender && (
{
order={order}
modal={modal}
>
- <>{children}>
+ <>{content}>
)}
@@ -69,14 +151,15 @@ export const Popover = (props: PopoverProps) => {
return (
- {isOpen && (
+ {shouldRender && (
- <>{children}>
+ <>{content}>
)}
diff --git a/packages/ui/src/elements/PopoverCard.tsx b/packages/ui/src/elements/PopoverCard.tsx
index f46f1431998..880b6f61d07 100644
--- a/packages/ui/src/elements/PopoverCard.tsx
+++ b/packages/ui/src/elements/PopoverCard.tsx
@@ -3,24 +3,13 @@ import React from 'react';
import { useEnvironment } from '../contexts';
import { Col, descriptors, Flex, Flow, useAppearance } from '../customizables';
import type { ElementDescriptor } from '../customizables/elementDescriptors';
-import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
-import { animations, common } from '../styledSystem';
+import type { PropsOfComponent } from '../styledSystem';
+import { common } from '../styledSystem';
import { colors } from '../utils/colors';
import { Card } from './Card';
-const PopoverCardRoot = React.forwardRef<
- HTMLDivElement,
- PropsOfComponent & {
- shouldEntryAnimate?: boolean;
- }
->((props, ref) => {
- const { elementDescriptor, shouldEntryAnimate = true, ...rest } = props;
-
- const withAnimation: ThemableCssProp = t => ({
- animation: shouldEntryAnimate
- ? `${animations.dropdownSlideInScaleAndFade} ${t.transitionDuration.$fast}`
- : undefined,
- });
+const PopoverCardRoot = React.forwardRef>((props, ref) => {
+ const { elementDescriptor, ...rest } = props;
return (
@@ -30,16 +19,13 @@ const PopoverCardRoot = React.forwardRef<
ref={ref}
// Popover cards always render as raised — flush is scoped to simple card components
elevation='raised'
- sx={[
- t => ({
- width: t.sizes.$94,
- maxWidth: `calc(100vw - ${t.sizes.$8})`,
- zIndex: t.zIndices.$modal,
- borderRadius: t.radii.$xl,
- outline: 'none',
- }),
- withAnimation,
- ]}
+ sx={t => ({
+ width: t.sizes.$94,
+ maxWidth: `calc(100vw - ${t.sizes.$8})`,
+ zIndex: t.zIndices.$modal,
+ borderRadius: t.radii.$xl,
+ outline: 'none',
+ })}
>
{props.children}
diff --git a/packages/ui/src/elements/Select.tsx b/packages/ui/src/elements/Select.tsx
index ec774a1e5e6..cb8e4dc1526 100644
--- a/packages/ui/src/elements/Select.tsx
+++ b/packages/ui/src/elements/Select.tsx
@@ -7,7 +7,7 @@ import { Button, descriptors, Flex, Icon, Input, Text } from '../customizables';
import { usePopover, useSearchInput } from '../hooks';
import { ChevronDown } from '../icons';
import type { PropsOfComponent, ThemableCssProp } from '../styledSystem';
-import { animations, common } from '../styledSystem';
+import { common } from '../styledSystem';
import { colors } from '../utils/colors';
import { withFloatingTree } from './contexts';
import type { InputWithIcon } from './InputWithIcon';
@@ -257,6 +257,11 @@ type SelectOptionListProps = PropsOfComponent & {
containerSx?: ThemableCssProp;
footer?: React.ReactNode;
onReachEnd?: () => void;
+ /**
+ * Scale the dropdown animates from on open. Defaults to the compact select value; raise it
+ * (e.g. `0.96`) for wider dropdowns like the phone country-code picker.
+ */
+ initialScale?: number;
};
export const SelectOptionList = (props: SelectOptionListProps) => {
@@ -265,6 +270,7 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
sx,
footer,
onReachEnd,
+ initialScale = 0.92,
id,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
@@ -353,7 +359,9 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
@@ -369,8 +377,6 @@ export const SelectOptionList = (props: SelectOptionListProps) => {
backgroundColor: colors.makeSolid(theme.colors.$colorBackground),
borderRadius: theme.radii.$lg,
overflow: 'hidden',
- animation: `${animations.dropdownSlideInScaleAndFade} ${theme.transitionDuration.$slower} ${theme.transitionTiming.$slowBezier}`,
- transformOrigin: 'top center',
boxShadow: theme.shadows.$menuShadow,
zIndex: theme.zIndices.$dropdown,
}),
diff --git a/packages/ui/src/elements/__tests__/Menu.test.tsx b/packages/ui/src/elements/__tests__/Menu.test.tsx
index 37afd2934d4..7b2d77a1490 100644
--- a/packages/ui/src/elements/__tests__/Menu.test.tsx
+++ b/packages/ui/src/elements/__tests__/Menu.test.tsx
@@ -85,7 +85,9 @@ describe('Menu', () => {
expect(getByRole('menu')).toBeInTheDocument();
await userEvent.click(getByRole('button', { name: 'Open menu' }));
- expect(queryByRole('menu')).not.toBeInTheDocument();
+ // The popover stays mounted through its exit transition, so the menu unmounts on
+ // animation-end rather than synchronously with the close.
+ await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument());
});
it('fires the menu item onClick and closes the menu on selection', async () => {
@@ -100,7 +102,7 @@ describe('Menu', () => {
await userEvent.click(getByRole('menuitem', { name: 'Remove' }));
expect(onClick).toHaveBeenCalledTimes(1);
- expect(queryByRole('menu')).not.toBeInTheDocument();
+ await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument());
});
it('keeps the menu open after selection when closeAfterClick is false', async () => {
@@ -277,7 +279,7 @@ describe('Menu', () => {
await userEvent.keyboard('{Enter}');
expect(onClick).toHaveBeenCalledTimes(1);
- expect(queryByRole('menu')).not.toBeInTheDocument();
+ await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument());
});
it('does not trap focus — Tab exits the menu', async () => {
@@ -305,7 +307,7 @@ describe('Menu', () => {
await userEvent.keyboard('{Escape}');
- expect(queryByRole('menu')).not.toBeInTheDocument();
+ await waitFor(() => expect(queryByRole('menu')).not.toBeInTheDocument());
expect(trigger).toHaveFocus();
});
diff --git a/packages/ui/src/foundations/transitions.ts b/packages/ui/src/foundations/transitions.ts
index 5ababb0ef22..b3eb64edf8d 100644
--- a/packages/ui/src/foundations/transitions.ts
+++ b/packages/ui/src/foundations/transitions.ts
@@ -3,6 +3,7 @@ const transitionDurationValues = Object.freeze({
slower: 280,
slow: 200,
fast: 120,
+ faster: 90,
focusRing: 200,
controls: 100,
textField: 450,
@@ -27,6 +28,7 @@ const transitionTiming = Object.freeze({
easeOut: 'ease-out',
bezier: 'cubic-bezier(0.32, 0.72, 0, 1)',
slowBezier: 'cubic-bezier(0.16, 1, 0.3, 1)',
+ swiftOut: 'cubic-bezier(0.175, 0.885, 0.32, 1.1)',
} as const);
export { transitionDuration, transitionTiming, transitionProperty, transitionDurationValues };