Skip to content

[Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before hiding - #506

Open
tvq wants to merge 3 commits into
ruby-ui:mainfrom
tvq:fix/overlay-exit-animation
Open

[Bug Fix] Popover, HoverCard, ContextMenu: play the exit animation before hiding#506
tvq wants to merge 3 commits into
ruby-ui:mainfrom
tvq:fix/overlay-exit-animation

Conversation

@tvq

@tvq tvq commented Aug 12, 2026

Copy link
Copy Markdown

Related issue

No existing issue — happy to open one if you'd prefer to track it separately.

Description

PopoverContent, HoverCardContent and ContextMenuContent all ship data-[state=closed]:animate-out fade-out-0 zoom-out-95, but those classes never get a frame. Each controller sets the closed state and applies hidden (display: none) in the same tick:

// popover_controller.js, before
this.contentTarget.classList.add("hidden");
this.contentTarget.dataset.state = "closed";

So the enter animation runs and the exit is a hard cut. This finishes what #495 started for the open direction — same argument, other half.

The fix defers hidden to the end of the exit animation, in one block that is byte-identical across the three controllers:

  • data-state="closed" first, hidden on animationend — the classes now get their frames.
  • animationcancel too — reopening mid-exit must not strand the pending hide.
  • Two guards on the eventanimationend bubbles, so an animated child must not hide its container; and closing during the opening animation cancels enter, so only the captured exit run settles it. Both mirror @radix-ui/react-presence.
  • No timeout fallback — the computed animation-name is read up front. If there is no exit animation to wait for (no tw-animate-css, overridden classes, or a consumer's own reduced-motion rule) the element hides at once instead of hanging on an event that never fires.
  • settleExit on disconnect — every other listener in these controllers is torn down there, and ContextMenu#disconnect calls hide(), which would otherwise arm handlers on an element it is dropping.
  • data-[state=closed]:fill-mode-forwards on the three content components — without it the element repaints at full opacity between the last keyframe and hidden, which flashes. TooltipContent already carries this class.

Kept as three copies rather than a shared module, matching how the eight overlay controllers each carry their own Floating UI plumbing — and because the generator installs components standalone, so a shared import would need a new dependency kind and has to resolve under importmap too. Happy to extract it instead if you'd rather.

Follow-ups

If this approach looks right, I'll send the same treatment for the rest in separate PRs:

  • DropdownMenu, ClipboardPopover — same, plus data-state which the controllers never set
  • Sheet, CommandDialogelement.remove() outright, so they need the state and the wait

Tooltip is already correct (it is the pattern this follows) and Toast has its own working variant. That would leave every overlay in the library animating out.

Testing instructions

The difference is motion, so a still screenshot shows nothing — the steps below are the check. cd gem && bundle exec rake covers the rendered fill-mode-forwards class; the lifecycle itself is JS:

  1. cd docs && bin/dev
  2. /docs/popover — open, then close via outside click and via Esc. It should fade + zoom out, not vanish.
  3. /docs/hover_card — hover on, hover off.
  4. /docs/context_menu — right click, then dismiss.
  5. Reopen mid-fade: it should come back without flicker or a stuck panel.
  6. Close mid-open (click the trigger twice in quick succession): the exit should still play in full.

Note on reduced motion: the library ships no prefers-reduced-motion rules today, so animate-in/animate-out run regardless — this PR neither improves nor regresses that. Happy to open a separate issue if library-wide reduced-motion support is wanted.

…fore hiding

All three set data-state="closed" and add `hidden` (display: none) in the
same frame, so data-[state=closed]:animate-out never gets one. Defer
`hidden` to animationend/animationcancel, and add fill-mode-forwards so
the last frame holds until it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@tvq
tvq requested a review from cirdes as a code owner August 12, 2026 22:30

@cubic-dev-ai cubic-dev-ai Bot 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.

1 issue found across 9 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="gem/lib/ruby_ui/context_menu/context_menu_controller.js">

<violation number="1" location="gem/lib/ruby_ui/context_menu/context_menu_controller.js:67">
P2: When `prefers-reduced-motion: reduce` is active, this controller still waits for the `animate-out` event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name `none`, so the menu hides immediately.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread gem/lib/ruby_ui/context_menu/context_menu_controller.js
Comment thread gem/lib/ruby_ui/hover_card/hover_card_controller.js
const styles = getComputedStyle(content);

// An element with no exit animation never fires animationend.
if (styles.animationName === "none" || styles.display === "none") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When prefers-reduced-motion: reduce is active, this controller still waits for the animate-out event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name none, so the menu hides immediately.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/context_menu/context_menu_controller.js, line 67:

<comment>When `prefers-reduced-motion: reduce` is active, this controller still waits for the `animate-out` event because the repository does not disable that animation automatically. Check the reduced-motion media query here, or provide a CSS rule that makes the animation name `none`, so the menu hides immediately.</comment>

<file context>
@@ -59,6 +59,36 @@ export default class extends Controller {
+    const styles = getComputedStyle(content);
+
+    // An element with no exit animation never fires animationend.
+    if (styles.animationName === "none" || styles.display === "none") {
+      this.hideUnlessReopened(content);
+      return;
</file context>
Suggested change
if (styles.animationName === "none" || styles.display === "none") {
if (
styles.animationName === "none" ||
styles.display === "none" ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Checked this one before acting on it, and I'd rather not take the suggestion — but the premise is right.

grep -c prefers-reduced-motion on the docs app's built application.css returns 0: the library ships no reduced-motion rules at all, so tw-animate-css animations run regardless of the setting. Where I think the conclusion differs is the consequence — the exit animation still runs and animationend still fires, so nothing hangs. Reduced-motion users see the same exit they already see on enter.

Adding matchMedia only here would make the close jump while the open still animates, which reads worse than the current consistency. And reduced-motion support looks library-wide to me — every animate-in in the overlay family, not just these three exits — so I'd rather not smuggle a partial version in under a bug fix. I've noted it in the PR description and am happy to open a separate issue for it.

The computed animation-name check earns its place for the cases it does cover: no tw-animate-css installed, overridden classes, or a consumer's own reduced-motion CSS.

Comment thread gem/lib/ruby_ui/popover/popover_controller.js
Comment thread gem/lib/ruby_ui/popover/popover_controller.js
Closing while the opening animation runs cancels `enter`, and that
animationcancel reached the handler as if the exit had finished. Capture
the exit animation-name when arming and ignore events from any other run.

disconnect() left the handlers attached, unlike every other listener in
these controllers; ContextMenu's disconnect armed them on an element it
was about to drop. It now applies the pending hide instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot 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.

All reported issues were addressed across 3 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread gem/lib/ruby_ui/context_menu/context_menu_controller.js Outdated
Comment thread gem/lib/ruby_ui/popover/popover_controller.js Outdated
animation-name is comma-separated when the content carries more than one
animation, while each event names a single run, so the equality check
rejected them all and the element never hid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant