From 7fb231c27259bec0cab16bdaf4988ae22554595c Mon Sep 17 00:00:00 2001 From: Max Yinger Date: Thu, 17 Sep 2026 12:00:44 -0600 Subject: [PATCH] fix(headless): hold menu contents through the exit animation Choosing an item usually changes what the menu was showing, so the popup swapped to the new content under its own exit animation. Wrap the popup's children in Freeze while closed, matching Popover, Select, Combobox and Dialog. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/mosaic-menu-exit-freeze.md | 5 ++ .claude/skills/mosaic/references/headless.md | 55 +++++++++++++++++++ .claude/skills/mosaic/references/motion.md | 9 +++ .../src/primitives/menu/menu-popup.tsx | 9 ++- 4 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 .changeset/mosaic-menu-exit-freeze.md 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({