fix(ui): isolate non-animated popover and tooltip cards from ancestor motion variants - #2782
Conversation
…over A MenuGroup submenu inside an animated MenuButton popover stays at opacity 0. The story asserts both popovers reach opacity 1; the submenu assertion fails. Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
… variants
motion treats any component with `variants` as a variant node and hands
it the variant labels of the nearest animating ancestor. The popover and
tooltip cards kept `variants` while dropping `initial`/`animate` when not
animating, so a non-animated card nested in an animated popover mounted
on the ancestor's `hidden` variant. Its visual element mounts late (React
`Activity` defers effects while hidden), after the ancestor's enter
animation has finished, so no propagated animation ever moved it to
`visible` and it stayed at opacity 0.
MenuGroup submenus inside a MenuButton with `popover={{animate: true}}`
were permanently invisible as a result.
POPOVER_MOTION_PROPS now holds every motion prop for the animated case,
and both cards spread it as a unit, so a non-animated card has no
`variants` and stays out of the variant tree entirely.
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
🦋 Changeset detectedLatest commit: d360690 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Fixes a bug where non-animated Popover/Tooltip cards could incorrectly participate in an ancestor popover’s motion variant tree, causing nested cards to mount on the ancestor’s hidden variant (opacity: 0) and remain invisible.
Changes:
- Centralized all motion-related props into
POPOVER_MOTION_PROPSand only spreads them whenanimateis enabled, preventing non-animated cards from being treated as variant nodes. - Updated
PopoverCardandTooltipCardto apply motion props atomically (all-or-nothing) instead of partially gating props. - Added Storybook
playtests that reproduce and assert the nested animated/non-animated visibility behavior, plus a changeset entry.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| packages/ui/src/core/primitives/tooltip/tooltipCard.tsx | Applies shared motion props only when animating to prevent variant inheritance from animated ancestors. |
| packages/ui/src/core/primitives/popover/popoverCard.tsx | Same all-or-nothing motion prop spread to isolate non-animated nested popovers from ancestor variants. |
| packages/ui/src/core/constants.ts | Refactors shared popover/tooltip motion config into a single MotionProps-shaped object with explicit contract in docs. |
| apps/storybook/stories/primitives/Popover.stories.tsx | Adds a nested-in-animated repro + play assertions covering nested Popover and Tooltip. |
| apps/storybook/stories/components/MenuButton.stories.tsx | Adds play coverage for the reported MenuGroup submenu-in-animated-MenuButton case. |
| .changeset/nested-popover-variant-inheritance.md | Patch changeset documenting the visibility fix and impacted scenario. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
What was broken
A non-animated
PopoverorTooltipnested inside a popover withanimate: truemounted atopacity: 0and never became visible.The reported symptom is
MenuGroupsubmenus in the StudioMenuButton, which forcesanimate: trueon its popover. Every submenu inUploadDropDownMenu,PaneMenuButtonItem,FieldActionMenuNodeand the release context menus was permanently invisible.TooltipCardcarried the identical defect, which gives a second consumer-facing symptom nobody had filed. A tooltip on a menu item inside an animatedMenuButtonpopover was invisible for the same reason, becauseMotionContextpropagates through the portal.Root cause
motiontreats any component with avariantsprop as a variant node. Frommotion-dom/dist/es/render/utils/is-controlling-variants.mjs:PopoverCardandTooltipCardgatedinitial/animate/exiton theanimateprop but passedvariantsunconditionally. So a non-animated card was a variant node that did not control its own variants, andmakeLatestValuesinframer-motion/dist/es/motion/utils/use-visual-state.mjsfilled in the missing labels from the ancestor'sMotionContext:The card therefore mounted on the animated ancestor's
hiddenvariant, atopacity: 0. It only leaves that variant if the ancestor changes label again, and a card that mounts after the ancestor's enter animation never sees another change. The mount is late because React'sActivitydefers effects while the nested popover is closed, so the visual element registers as a variant child of the ancestor only once the submenu opens, by which point the enter animation is long finished.That late mount is why the bug only shows when the nested popover opens after the outer one has settled. A nested card that mounts in the same commit as the outer card gets carried along by the outer animation and looks fine.
The fix
POPOVER_MOTION_PROPSnow holds every motion prop for the animated case, and both cards spread it as a unit:A non-animated card now receives no
variantsat all, soisVariantNodeis false. It neither inherits variant labels at mount nor gets registered as a variant child of an animated ancestor. One object replaces ten conditional lines across two files, so "not animated means no motion participation" can no longer be half-applied.The all-or-nothing spread is stronger than making only
variantsconditional, because motion gates the whole animation feature on the presence ofanimate/variants/exit/while*(framer-motion/dist/es/motion/features/definitions.mjs). With none of them the card has noanimationStateat all and opts out of the animation system rather than just the variant tree. Droppingtransitionis safe for the same reason, and motion only applies the default transition to layout animation whenlayoutorlayoutIdis present, which neither card sets.Rejected alternatives
inherit={false}on the non-animated card. It blocks the mount-time inheritance inmakeLatestValues, butVisualElement.mountstill callsaddVariantChildbecause that check reads onlyisVariantNode/isControllingVariantsand ignoresinherit.animateChildrenthen walksvariantChildrenon every ancestor label change, so exit and gesture state would still propagate to a supposedly non-animated popover. It is a mount-state patch, not variant-tree isolation.Cardinstead ofMotionCardwhenanimateis false. Fully isolated, but a bigger diff and two styled components to keep visually identical, for no behavioral gain over the spread.variantsconditional. The smallest possible diff, but it leaves the animation-mode decision split across two files, which is how the bug arose.Note for future maintenance
prefers-reduced-motioncan flipanimatewhile a popover is open. That is safe today because the flip swaps<AnimateActivity>for<Activity>, two different component types at the same position, so React remounts the card subtree and the card is reconstructed with a consistent prop set. It matters because motion computesisVariantNodeandisControllingVariantsin theVisualElementconstructor andupdate()never recomputes them. If popover.tsx or tooltip.tsx is ever simplified to always renderAnimateActivity, a card flippinganimatemid-mount would change prop shape without motion noticing. The comment onPOPOVER_MOTION_PROPScarries that contract, so keep it ifconstants.tsis reorganized.One upstream inconsistency worth filing against motion:
makeLatestValueshonorsprops.inheritbutVisualElement.mountandanimateChildrendo not, soinherit={false}silently does less than its documentation implies.Verification
Two story
playfunctions assert computed opacity and both fail without the fix:AnimatedWithMenuGroupinapps/storybook/stories/components/MenuButton.stories.tsxcovers the reportedMenuGroupcase.NestedInAnimatedinapps/storybook/stories/primitives/Popover.stories.tsxcovers a nestedPopoverand a nestedTooltip. Reverting the tooltip half alone makes only the tooltip step fail, so each instance is independently proven.The repro commit lands before the fix, so the history shows red then green.
pnpm lint,pnpm test(106 unit tests),pnpm test:browser(247 browser tests) andpnpm knipall pass locally, and CI is green on every required check.Every other
variants=call site inpackages/ui/srcwas audited.Toastsets unconditional variant labels so it always controls its own variants and handles reduced motion by zeroing durations rather than dropping props. ItsMotionFlex/MotionText/MotionLoadingBarchildren are deliberate variant children whose nearest controlling ancestor is alwaysMotionToast, so its freshMotionContextshadows any outer animated popover.MotionLoadingBarProgressanimates target objects and has novariants, andDialoganimates via a styled-components prop rather than motion. Popover and Tooltip were the only two instances.The same story driven through Playwright against
pnpm dev, before and after:MenuGroup submenu invisible before the fix
MenuGroup submenu visible after the fix
To show artifacts inline, enable in settings.