From ad67576a6af93fc680558ea48886f1e436911009 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Wed, 8 Jul 2026 18:19:33 -0600 Subject: [PATCH 1/8] snappier in. fade out. new new --- .changeset/popover-enter-exit-animations.md | 5 +++ .../OrganizationSwitcherPopover.tsx | 3 +- .../components/OrganizationSwitcher/index.tsx | 2 +- .../UserButton/UserButtonPopover.tsx | 3 +- .../ui/src/components/UserButton/index.tsx | 2 +- packages/ui/src/elements/Popover.tsx | 43 ++++++++++++++++--- 6 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 .changeset/popover-enter-exit-animations.md diff --git a/.changeset/popover-enter-exit-animations.md b/.changeset/popover-enter-exit-animations.md new file mode 100644 index 00000000000..9b2fe030935 --- /dev/null +++ b/.changeset/popover-enter-exit-animations.md @@ -0,0 +1,5 @@ +--- +'@clerk/ui': patch +--- + +The `` and `` popovers now open instantly and fade out on close, replacing the previous slide-and-scale entrance animation. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion. diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 617ba54c300..1df6aa77fce 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,7 @@ 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..5067db161e6 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,7 @@ 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/Popover.tsx b/packages/ui/src/elements/Popover.tsx index 9a2ac5a8e0c..a95cfb79527 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -1,14 +1,23 @@ 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 { usePrefersReducedMotion } from '../hooks'; + type PopoverProps = PropsWithChildren<{ context: FloatingContext; nodeId?: string; isOpen?: boolean; initialFocus?: number | React.MutableRefObject; + /** + * When `true`, the popover appears instantly but stays mounted through a short fade-out on close, + * instead of unmounting immediately. Opt-in so other consumers keep their current instant behavior. + * @default false + */ + animateExit?: boolean; /** * Determines whether outside elements are inert when modal is enabled. This enables pointer modality without a backdrop. * @default false @@ -39,6 +48,7 @@ export const Popover = (props: PopoverProps) => { modal = true, nodeId, isOpen, + animateExit = false, portal = true, root, children, @@ -47,11 +57,34 @@ export const Popover = (props: PopoverProps) => { const portalRoot = usePortalRoot(); const effectiveRoot = root ?? portalRoot?.() ?? undefined; + const prefersReducedMotion = usePrefersReducedMotion(); + const { animations: layoutAnimations } = useAppearance().parsedOptions; + const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; + + // Appear instantly for snappiness, fade out on close. Keeping the element mounted + // through the close transition (via `isMounted`) is what makes the exit animation possible. + const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { + // `close` matches the theme's `$slow` (200ms) transition duration. + duration: { open: 0, close: animateExit && isMotionSafe ? 200 : 0 }, + initial: { opacity: 1 }, + open: { opacity: 1 }, + close: { opacity: animateExit ? 0 : 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 +102,14 @@ export const Popover = (props: PopoverProps) => { return ( - {isOpen && ( + {shouldRender && ( - <>{children} + <>{content} )} From 5857315c58bd70f38669fc0d640ab3dac6fc19cd Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 12:52:02 -0600 Subject: [PATCH 2/8] animation refinements --- .changeset/popover-enter-exit-animations.md | 2 +- packages/ui/src/elements/Popover.tsx | 34 +++++++++++++++------ packages/ui/src/foundations/transitions.ts | 1 + 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/.changeset/popover-enter-exit-animations.md b/.changeset/popover-enter-exit-animations.md index 9b2fe030935..1a2949ef392 100644 --- a/.changeset/popover-enter-exit-animations.md +++ b/.changeset/popover-enter-exit-animations.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -The `` and `` popovers now open instantly and fade out on close, replacing the previous slide-and-scale entrance animation. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion. +The `` and `` popovers now open and close with an origin-aware scale-and-fade animation that grows from the edge nearest the trigger. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion. diff --git a/packages/ui/src/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx index a95cfb79527..1e332430846 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -5,6 +5,7 @@ import type { PropsWithChildren } from 'react'; import React from 'react'; import { useAppearance } from '../customizables'; +import { transitionDurationValues, transitionTiming } from '../foundations/transitions'; import { usePrefersReducedMotion } from '../hooks'; type PopoverProps = PropsWithChildren<{ @@ -13,8 +14,9 @@ type PopoverProps = PropsWithChildren<{ isOpen?: boolean; initialFocus?: number | React.MutableRefObject; /** - * When `true`, the popover appears instantly but stays mounted through a short fade-out on close, - * instead of unmounting immediately. Opt-in so other consumers keep their current instant behavior. + * 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; @@ -60,15 +62,29 @@ export const Popover = (props: PopoverProps) => { const prefersReducedMotion = usePrefersReducedMotion(); const { animations: layoutAnimations } = useAppearance().parsedOptions; const isMotionSafe = !prefersReducedMotion && layoutAnimations === true; + const animate = animateExit && isMotionSafe; - // Appear instantly for snappiness, fade out on close. Keeping the element mounted - // through the close transition (via `isMounted`) is what makes the exit animation possible. + // 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 edge nearest its trigger (Floating UI's placement-aware transitions). const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { - // `close` matches the theme's `$slow` (200ms) transition duration. - duration: { open: 0, close: animateExit && isMotionSafe ? 200 : 0 }, - initial: { opacity: 1 }, - open: { opacity: 1 }, - close: { opacity: animateExit ? 0 : 1 }, + duration: { + open: animate ? transitionDurationValues.fast : 0, + close: animate ? transitionDurationValues.slower : 0, + }, + common: ({ side }) => ({ + transformOrigin: { + top: 'bottom', + bottom: 'top', + left: 'right', + right: 'left', + }[side], + transitionTimingFunction: transitionTiming.swiftOut, + }), + initial: { opacity: animate ? 0 : 1, transform: animate ? 'scale(0.96)' : 'scale(1)' }, + open: { opacity: 1, transform: 'scale(1)' }, + close: { opacity: animate ? 0 : 1, transform: animate ? 'scale(0.96)' : 'scale(1)' }, }); // Non-animating consumers keep the original synchronous `isOpen` unmount. diff --git a/packages/ui/src/foundations/transitions.ts b/packages/ui/src/foundations/transitions.ts index 5ababb0ef22..637b7d38e51 100644 --- a/packages/ui/src/foundations/transitions.ts +++ b/packages/ui/src/foundations/transitions.ts @@ -27,6 +27,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 }; From 3c7be4d1d448805ba42c160ae414b97ca9856caa Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 14:06:24 -0600 Subject: [PATCH 3/8] update origin, add select and menu transitions --- .changeset/popover-enter-exit-animations.md | 2 +- packages/ui/src/elements/Menu.tsx | 31 +++++----- packages/ui/src/elements/PhoneInput/index.tsx | 1 + packages/ui/src/elements/Popover.tsx | 59 +++++++++++++++---- packages/ui/src/elements/Select.tsx | 14 +++-- packages/ui/src/foundations/transitions.ts | 1 + 6 files changed, 75 insertions(+), 33 deletions(-) diff --git a/.changeset/popover-enter-exit-animations.md b/.changeset/popover-enter-exit-animations.md index 1a2949ef392..063d77d079f 100644 --- a/.changeset/popover-enter-exit-animations.md +++ b/.changeset/popover-enter-exit-animations.md @@ -2,4 +2,4 @@ '@clerk/ui': patch --- -The `` and `` popovers now open and close with an origin-aware scale-and-fade animation that grows from the edge nearest the trigger. Motion is disabled when `appearance.animations` is `false` or the user prefers reduced motion. +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/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 1e332430846..5d5c91c245c 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { useAppearance } from '../customizables'; import { transitionDurationValues, transitionTiming } from '../foundations/transitions'; -import { usePrefersReducedMotion } from '../hooks'; +import { useDirection, usePrefersReducedMotion } from '../hooks'; type PopoverProps = PropsWithChildren<{ context: FloatingContext; @@ -20,6 +20,19 @@ type PopoverProps = PropsWithChildren<{ * @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 @@ -41,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, @@ -51,6 +85,8 @@ export const Popover = (props: PopoverProps) => { nodeId, isOpen, animateExit = false, + exitFast = false, + initialScale = 0.96, portal = true, root, children, @@ -60,31 +96,28 @@ export const Popover = (props: PopoverProps) => { 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 edge nearest its trigger (Floating UI's placement-aware transitions). + // the point nearest its trigger — the aligned corner when present, otherwise the edge center. const { isMounted, styles: transitionStyles } = useTransitionStyles(context, { duration: { - open: animate ? transitionDurationValues.fast : 0, - close: animate ? transitionDurationValues.slower : 0, + open: animate ? transitionDurationValues.faster : 0, + close: animate ? closeDuration : 0, }, - common: ({ side }) => ({ - transformOrigin: { - top: 'bottom', - bottom: 'top', - left: 'right', - right: 'left', - }[side], + common: ({ placement }) => ({ + transformOrigin: getTransformOrigin(placement, direction), transitionTimingFunction: transitionTiming.swiftOut, }), - initial: { opacity: animate ? 0 : 1, transform: animate ? 'scale(0.96)' : 'scale(1)' }, + 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(0.96)' : 'scale(1)' }, + close: { opacity: animate ? 0 : 1, transform: animate ? `scale(${initialScale})` : 'scale(1)' }, }); // Non-animating consumers keep the original synchronous `isOpen` unmount. 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/foundations/transitions.ts b/packages/ui/src/foundations/transitions.ts index 637b7d38e51..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, From b9fd172e91433513fb13e822ad553cb3cbf651a2 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 14:29:52 -0600 Subject: [PATCH 4/8] fix dropped prop and bump ui common cieling --- packages/ui/bundlewatch.config.json | 2 +- packages/ui/src/elements/Popover.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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/elements/Popover.tsx b/packages/ui/src/elements/Popover.tsx index 5d5c91c245c..13882e9bfa8 100644 --- a/packages/ui/src/elements/Popover.tsx +++ b/packages/ui/src/elements/Popover.tsx @@ -155,6 +155,7 @@ export const Popover = (props: PopoverProps) => { From a934f08b21b574b14df63030a425dc0303c705f3 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 14:48:59 -0600 Subject: [PATCH 5/8] fix unit tests --- packages/ui/src/elements/__tests__/Menu.test.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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(); }); From f4898356ade97ebfd84c518624d3ff28daf58c97 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 15:23:22 -0600 Subject: [PATCH 6/8] fix integration tests --- integration/tests/custom-pages.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) 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(); From bbe573c4265d3b1962abf343bfc47f2dbe68dc19 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 15:35:51 -0600 Subject: [PATCH 7/8] cleanup --- .../OrganizationSwitcherPopover.tsx | 1 - .../UserButton/UserButtonPopover.tsx | 1 - packages/ui/src/elements/PopoverCard.tsx | 36 ++++++------------- 3 files changed, 11 insertions(+), 27 deletions(-) diff --git a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx index 1df6aa77fce..113a196958b 100644 --- a/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx +++ b/packages/ui/src/components/OrganizationSwitcher/OrganizationSwitcherPopover.tsx @@ -137,7 +137,6 @@ export const OrganizationSwitcherPopover = React.forwardRef diff --git a/packages/ui/src/components/UserButton/UserButtonPopover.tsx b/packages/ui/src/components/UserButton/UserButtonPopover.tsx index 5067db161e6..b0d8dad7dff 100644 --- a/packages/ui/src/components/UserButton/UserButtonPopover.tsx +++ b/packages/ui/src/components/UserButton/UserButtonPopover.tsx @@ -39,7 +39,6 @@ export const UserButtonPopover = React.forwardRef 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} From ab7d0765a91801fde7183d2becc9c70660b5bb67 Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 9 Jul 2026 15:41:59 -0600 Subject: [PATCH 8/8] more integration fixes --- .../testing/src/playwright/unstable/page-objects/apiKeys.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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;