Skip to content

fix(ui): isolate non-animated popover and tooltip cards from ancestor motion variants - #2782

Merged
stipsan merged 5 commits into
mainfrom
cursor/fix-nested-popover-variant-inheritance-c773
Aug 27, 2026
Merged

stipsan merged 5 commits into
mainfrom
cursor/fix-nested-popover-variant-inheritance-c773

Conversation

@stipsan

@stipsan stipsan commented Aug 26, 2026

Copy link
Copy Markdown
Member

What was broken

A non-animated Popover or Tooltip nested inside a popover with animate: true mounted at opacity: 0 and never became visible.

The reported symptom is MenuGroup submenus in the Studio MenuButton, which forces animate: true on its popover. Every submenu in UploadDropDownMenu, PaneMenuButtonItem, FieldActionMenuNode and the release context menus was permanently invisible.

TooltipCard carried the identical defect, which gives a second consumer-facing symptom nobody had filed. A tooltip on a menu item inside an animated MenuButton popover was invisible for the same reason, because MotionContext propagates through the portal.

Root cause

motion treats any component with a variants prop as a variant node. From motion-dom/dist/es/render/utils/is-controlling-variants.mjs:

function isVariantNode(props) {
    return Boolean(isControllingVariants(props) || props.variants);
}

PopoverCard and TooltipCard gated initial/animate/exit on the animate prop but passed variants unconditionally. So a non-animated card was a variant node that did not control its own variants, and makeLatestValues in framer-motion/dist/es/motion/utils/use-visual-state.mjs filled in the missing labels from the ancestor's MotionContext:

if (context && isVariantNode && !isControllingVariants && props.inherit !== false) {
    if (initial === undefined) initial = context.initial;
    if (animate === undefined) animate = context.animate;
}

The card therefore mounted on the animated ancestor's hidden variant, at opacity: 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's Activity defers 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_PROPS now holds every motion prop for the animated case, and both cards spread it as a unit:

{...(animate ? POPOVER_MOTION_PROPS : undefined)}

A non-animated card now receives no variants at all, so isVariantNode is 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 variants conditional, because motion gates the whole animation feature on the presence of animate/variants/exit/while* (framer-motion/dist/es/motion/features/definitions.mjs). With none of them the card has no animationState at all and opts out of the animation system rather than just the variant tree. Dropping transition is safe for the same reason, and motion only applies the default transition to layout animation when layout or layoutId is present, which neither card sets.

Rejected alternatives

  • inherit={false} on the non-animated card. It blocks the mount-time inheritance in makeLatestValues, but VisualElement.mount still calls addVariantChild because that check reads only isVariantNode/isControllingVariants and ignores inherit. animateChildren then walks variantChildren on 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.
  • Rendering a plain Card instead of MotionCard when animate is false. Fully isolated, but a bigger diff and two styled components to keep visually identical, for no behavioral gain over the spread.
  • Making only variants conditional. The smallest possible diff, but it leaves the animation-mode decision split across two files, which is how the bug arose.
  • A helper that returns the props. Marginally stronger encapsulation, not worth the indirection for two call sites.

Note for future maintenance

prefers-reduced-motion can flip animate while 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 computes isVariantNode and isControllingVariants in the VisualElement constructor and update() never recomputes them. If popover.tsx or tooltip.tsx is ever simplified to always render AnimateActivity, a card flipping animate mid-mount would change prop shape without motion noticing. The comment on POPOVER_MOTION_PROPS carries that contract, so keep it if constants.ts is reorganized.

One upstream inconsistency worth filing against motion: makeLatestValues honors props.inherit but VisualElement.mount and animateChildren do not, so inherit={false} silently does less than its documentation implies.

Verification

Two story play functions assert computed opacity and both fail without the fix:

  • AnimatedWithMenuGroup in apps/storybook/stories/components/MenuButton.stories.tsx covers the reported MenuGroup case.
  • NestedInAnimated in apps/storybook/stories/primitives/Popover.stories.tsx covers a nested Popover and a nested Tooltip. 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.

=== RED: packages/ui at 32d938912 (repro commit, fix not yet applied) ===
   × Animated With Menu Group 1167ms
   × Nested In Animated 1131ms
 FAIL  |storybook (chromium)| stories/components/MenuButton.stories.tsx > Animated With Menu Group
Expected: "1"
Received: "0"
 FAIL  |storybook (chromium)| stories/primitives/Popover.stories.tsx > Nested In Animated
Expected: "1"
Received: "0"
 Test Files  2 failed (2)
      Tests  2 failed | 27 passed (29)

=== GREEN: packages/ui at HEAD (fix applied) ===
 Test Files  2 passed (2)
      Tests  29 passed (29)

pnpm lint, pnpm test (106 unit tests), pnpm test:browser (247 browser tests) and pnpm knip all pass locally, and CI is green on every required check.

Every other variants= call site in packages/ui/src was audited. Toast sets unconditional variant labels so it always controls its own variants and handles reduced motion by zeroing durations rather than dropping props. Its MotionFlex/MotionText/MotionLoadingBar children are deliberate variant children whose nearest controlling ancestor is always MotionToast, so its fresh MotionContext shadows any outer animated popover. MotionLoadingBarProgress animates target objects and has no variants, and Dialog animates 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.

Open in Web Open in Cursor 

cursoragent and others added 3 commits August 26, 2026 21:36
…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-bot

changeset-bot Bot commented Aug 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: d360690

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@sanity/ui Patch
@sanity/themer Patch

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

@vercel

vercel Bot commented Aug 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
icons Ready Ready Preview Aug 26, 2026 9:54pm
sanity-ui-storybook Ready Ready Preview Aug 26, 2026 9:54pm
ui-docs Ready Ready Preview Aug 26, 2026 9:54pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
sanity-ui-workshop Ignored Ignored Aug 26, 2026 9:54pm

Request Review

Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>
Co-authored-by: Cody Olsen <stipsan@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_PROPS and only spreads them when animate is enabled, preventing non-animated cards from being treated as variant nodes.
  • Updated PopoverCard and TooltipCard to apply motion props atomically (all-or-nothing) instead of partially gating props.
  • Added Storybook play tests 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.

@stipsan
stipsan merged commit 80d25c2 into main Aug 27, 2026
16 checks passed
@stipsan
stipsan deleted the cursor/fix-nested-popover-variant-inheritance-c773 branch August 27, 2026 09:11
@squiggler-app squiggler-app Bot mentioned this pull request Aug 27, 2026

This branch was successfully deployed

3 active deployments
Preview – ui-docs d3606905 Deployed Aug 26, 2026 by vercel[bot]
Preview – sanity-ui-storybook d3606905 Deployed Aug 26, 2026 by vercel[bot]
Preview – icons d3606905 Deployed Aug 26, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants