diff --git a/.changeset/mosaic-menu-exit-freeze.md b/.changeset/mosaic-menu-exit-freeze.md
new file mode 100644
index 00000000000..bce9f3bb0d8
--- /dev/null
+++ b/.changeset/mosaic-menu-exit-freeze.md
@@ -0,0 +1,5 @@
+---
+'@clerk/mosaic': patch
+---
+
+`Menu.Popup` now holds its contents while it animates closed.
diff --git a/.claude/skills/mosaic/references/headless.md b/.claude/skills/mosaic/references/headless.md
index b1c8ab4c7eb..d70cc1c168e 100644
--- a/.claude/skills/mosaic/references/headless.md
+++ b/.claude/skills/mosaic/references/headless.md
@@ -211,6 +211,57 @@ attributes and drive unmount off the Web Animations API. Root spreads
Consumer CSS keys off these: `[data-starting-style] { opacity: 0 }`,
`[data-open] { animation: … }`, `[data-ending-style] { opacity: 0 }`.
+### Exiting content must be frozen
+
+Anything that animates out outlives `open` by the length of its exit, and
+whatever closed it has usually already changed the data behind it — picking a
+menu item, selecting an option, switching account, a machine returning to `idle`,
+a form clearing. The subtree re-renders with the new data and swaps visibly under
+the exit, which reads as a flash of the next screen. This applies to **every part
+with an exit transition**, not only popups: a sheet, a panel, a step, an inline
+region that fades out all have the same window.
+
+Two ways to hold the old frame, depending on whether the part re-renders for
+reasons of its own while closed:
+
+**`Freeze`** — wrap the children, keep the animating element live:
+
+```tsx
+const { render, children, ...otherProps } = props;
+const { open, popupRef, transitionProps } = useMenuContext();
+
+const defaultProps = {
+ ...transitionProps,
+ children: {children},
+};
+```
+
+**A held snapshot** — keep the last open `children` in a ref and render those
+while closed. `FlowStep` does this: its `children` prop genuinely changes when the
+step value moves on, so there is a correct old value to render rather than a frame
+to hold.
+
+```tsx
+if (open) activeChildrenRef.current = children;
+// ...
+children: open ? children : activeChildrenRef.current,
+```
+
+Reach for `Freeze` by default; reach for the snapshot when the outgoing content is
+a distinct element you still have.
+
+Two things to get right either way:
+
+- Gate on **`!open`**, never `!mounted`. `mounted` stays true through the whole
+ exit — that is precisely the window this covers, so `!mounted` freezes nothing.
+- Freeze the **children**, not the animating element. It has to stay live for
+ `data-closed` / `data-ending-style` to land on it and for the animation to run.
+
+In place today: Popover, Select, Combobox, Menu, Dialog (`Freeze`), Flow
+(snapshot). Not yet: Drawer, Autocomplete, Tooltip, and the Accordion /
+Collapsible / Tabs panels. Any new part with an exit transition needs one of the
+two.
+
**Positioners** gate the floating layer on `mounted` via `useRender`'s
`enabled`, so the positioned DOM doesn't exist until the first frame:
@@ -232,6 +283,10 @@ if (!element) return null;
## Shared utils (`@clerk/headless/utils`)
- **`useRender`, `mergeProps`, `ComponentProps`, `DefaultProps`, `RenderProp`** — the part-authoring primitives (above).
+- **`Freeze({ frozen, children })`** — holds its subtree's DOM at the last
+ committed frame while `frozen` (a suspended boundary whose `display: none` is
+ undone in an insertion effect). Wrap a transitioning popup's children in it so
+ they don't swap under the exit animation; see above.
- **`cssVars({ sideOffset? }): Middleware`** — floating-ui middleware setting
`--cl-anchor-width/height`, `--cl-available-width/height`, `--cl-transform-origin`
on the floating element. Place it **after** `arrow()`.
diff --git a/.claude/skills/mosaic/references/motion.md b/.claude/skills/mosaic/references/motion.md
index 1b9eb34ebc5..1b49016aa51 100644
--- a/.claude/skills/mosaic/references/motion.md
+++ b/.claude/skills/mosaic/references/motion.md
@@ -121,6 +121,15 @@ opacity-at-overshoot-peak from 0.78 to 1.00.
**Exits land together.** Do _not_ split them going out. Matching durations are
what stop an exit reading as a lingering ghost.
+## An exit must not show new content
+
+An element that animates out is still mounted, and by then the thing that closed
+it has usually changed the data underneath — so the subtree re-renders and the
+exit plays over the _next_ screen's content. It reads as a flash. Whatever you
+animate out, hold its last frame for the length of the exit: `Freeze` around the
+children, or a snapshot of the outgoing content. See "Exiting content must be
+frozen" in `headless.md` for which to use and the two ways to get it wrong.
+
## Color and state changes (hover, press)
A state change on an element that is already there and stays there — background,
diff --git a/packages/headless/src/primitives/menu/menu-popup.tsx b/packages/headless/src/primitives/menu/menu-popup.tsx
index 5eb0fa511f5..25d4060d621 100644
--- a/packages/headless/src/primitives/menu/menu-popup.tsx
+++ b/packages/headless/src/primitives/menu/menu-popup.tsx
@@ -2,17 +2,20 @@
import React from 'react';
-import { type ComponentProps, mergeProps, useRender } from '../../utils';
+import { type ComponentProps, Freeze, mergeProps, useRender } from '../../utils';
import { useMenuContext } from './menu-context';
export type MenuPopupProps = ComponentProps<'div'>;
export const MenuPopup = React.forwardRef(function MenuPopup(props, ref) {
- const { render, ...otherProps } = props;
- const { popupRef, transitionProps } = useMenuContext();
+ const { render, children, ...otherProps } = props;
+ const { open, popupRef, transitionProps } = useMenuContext();
const defaultProps = {
...transitionProps,
+ // Choosing an item usually changes what the menu was showing, so the contents hold their
+ // last frame on the way out instead of swapping under the exit animation.
+ children: {children},
};
return useRender({