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({