Skip to content

[Improvement] Add exit animation, remove delay, lazy-mount Tooltip content#393

Merged
cirdes merged 2 commits into
ruby-ui:mainfrom
pierry01:shadcn-tooltip
May 12, 2026
Merged

[Improvement] Add exit animation, remove delay, lazy-mount Tooltip content#393
cirdes merged 2 commits into
ruby-ui:mainfrom
pierry01:shadcn-tooltip

Conversation

@pierry01
Copy link
Copy Markdown
Contributor

@pierry01 pierry01 commented May 12, 2026

Related issue

N/A — alignment with shadcn/ui defaults and behavior, following the Preserving the shadcn look and feel focus area in CONTRIBUTING.md.

Description

Three issues with the current Tooltip, all fixed against shadcn's reference implementation:

1. No close animation

The current peer-hover:visible + peer-hover:animate-in pattern toggles visibility binarily — when the trigger is no longer hovered, the content's classes drop and the element snaps away with no exit animation playing.

Switched to the data-state="open"|"closed" pattern that shadcn/Radix use, with paired Tailwind variants:

"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95"
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards"

fill-mode-forwards on the closed state is required because tw-animate-css defaults to animation-fill-mode: none. Without it, the animate-out keyframes play opacity 1 → 0 and the element then snaps back to opacity: 1. With forwards, it stays at the final keyframe and is invisible until the next open.

2. Hardcoded 500ms delay

The current code applies delay-500 unconditionally, which delays both open and close. shadcn overrides Radix's 700ms default with delayDuration: 0 — the tooltip opens instantly when the cursor enters the trigger.

Removed delay-500 entirely. Hover/focus now opens the tooltip immediately.

3. Eager rendering of every tooltip's HTML

Even when a page has 30 tooltips and the user never hovers any of them, each TooltipContent div is rendered into the active DOM and gets full CSS matching, layout, and Stimulus target scanning.

Now the content is wrapped in a <template> element. <template> children live in a DocumentFragment outside the active document tree — the browser does not apply CSS to them, does not compute layout, and Stimulus does not scan them for targets. On the first hover/focus, the controller clones the template's child into document.body, positions it with Floating UI, and toggles data-state. On the close animation's animationend, it unmounts (removes from body, stops autoUpdate).

Other supporting changes

  • Trigger now wires events through Stimulus actions (mouseenter / mouseleave / focus / blur on a single data-action) instead of CSS sibling selectors. This also lets the cloned content live outside the trigger's subtree (in body), avoiding clipping by overflow: hidden ancestors.
  • class: \"peer\" removed from the trigger — the sibling-selector pattern is gone.
  • turbo:before-cache listener unmounts the tooltip before Turbo snapshots the page, keeping cached snapshots clean (a tooltip in body would otherwise be orphaned from its controller on cache restore).
  • pointer-events-none on the content base — the cloned element stays in the layout at opacity: 0 after close (because of fill-mode-forwards), so it shouldn't capture the cursor.

Testing instructions

  1. Hover any tooltip trigger → tooltip appears instantly with fade-in + zoom-in + slide-in (no 500ms wait).
  2. Leave the trigger → tooltip plays fade-out + zoom-out and disappears.
  3. Hover quickly in/out/in → the close animation is interrupted by the open, no flicker.
  4. Inspect DOM before hovering: no tooltip-content-* element exists in body.
  5. Inspect DOM after hovering and leaving: tooltip-content-* is removed from body once the exit animation ends.
  6. Navigate to another page and back (Turbo cache) → tooltip still works.
  7. cd gem && bundle exec rake → tests + standardrb pass (existing assertions on w-fit, max-w-[calc(100vw-2rem)], break-words are preserved).

@pierry01 pierry01 requested a review from cirdes as a code owner May 12, 2026 14:25
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

No issues found across 8 files

@pierry01 pierry01 marked this pull request as draft May 12, 2026 14:28
…ntent

Aligns the Tooltip with shadcn/ui's defaults and behavior:

- Switch from `data-show="true"` (binary) to `data-state="open"|"closed"`,
  enabling clean enter/exit animations driven by paired
  `data-[state=open]:*` / `data-[state=closed]:*` Tailwind classes.
- Add `data-[state=closed]:fill-mode-forwards` so the exit animation
  persists at its final keyframe (opacity 0) — without it, `tw-animate-css`
  defaults to `fill-mode: none` and the content snaps back to opacity 1
  after the animation completes.
- Remove the 500ms delay — shadcn overrides Radix's 700ms default with
  `delayDuration: 0`, so the tooltip now opens instantly on hover/focus.
- Lazy-mount the content via `<template>`: the content lives inert in a
  `DocumentFragment` until first hover; the controller clones it into
  `document.body` on `show`, unmounts on the close animation's
  `animationend`. Avoids paying parse + CSS + Stimulus target scan cost
  for tooltips the user never interacts with.
- Add `turbo:before-cache` listener so the tooltip is removed from `body`
  before Turbo snapshots the page, keeping the cache clean.
- Wire the trigger through Stimulus actions (`mouseenter` / `mouseleave`
  / `focus` / `blur`) on a single `data-action`, instead of relying on
  CSS sibling selectors (`peer-hover` / `peer-focus`).
- Drop `class: "peer"` from the trigger — the sibling pattern is no
  longer used.
@pierry01 pierry01 changed the title [Improvement] Align Tooltip with shadcn API and behavior [Improvement] Add exit animation, remove delay, lazy-mount Tooltip content May 12, 2026
Improves readability by grouping related Tailwind variants
(placement, state) and separating each Stimulus action descriptor
on its own line.
@pierry01 pierry01 marked this pull request as ready for review May 12, 2026 14:36
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

1 issue found across 4 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/tooltip/tooltip_controller.js">

<violation number="1" location="gem/lib/ruby_ui/tooltip/tooltip_controller.js:7">
P1: Invalid Stimulus values definition syntax. Passing a raw string `"top"` as the value definition is not supported by Stimulus — it expects either a Type constructor or a `{ type, default }` object. This will cause `this.placementValue` to not work correctly, breaking tooltip positioning.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

constructor(...args) {
super(...args);
this.cleanup;
static values = { placement: "top" };
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot May 12, 2026

Choose a reason for hiding this comment

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

P1: Invalid Stimulus values definition syntax. Passing a raw string "top" as the value definition is not supported by Stimulus — it expects either a Type constructor or a { type, default } object. This will cause this.placementValue to not work correctly, breaking tooltip positioning.

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

<comment>Invalid Stimulus values definition syntax. Passing a raw string `"top"` as the value definition is not supported by Stimulus — it expects either a Type constructor or a `{ type, default }` object. This will cause `this.placementValue` to not work correctly, breaking tooltip positioning.</comment>

<file context>
@@ -3,36 +3,72 @@ import { computePosition, autoUpdate, offset, shift } from "@floating-ui/dom";
-  constructor(...args) {
-    super(...args);
-    this.cleanup;
+  static values = { placement: "top" };
+
+  mount() {
</file context>
Suggested change
static values = { placement: "top" };
static values = { placement: { type: String, default: "top" } };
Fix with Cubic

Copy link
Copy Markdown
Contributor

@djalmaaraujo djalmaaraujo left a comment

Choose a reason for hiding this comment

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

LGTM 👏 Lazy-mount via <template> + portal to body is the right approach — avoids CSS matching / layout for unused tooltips and dodges clipping by overflow: hidden ancestors in the trigger subtree.

Controller lifecycle is clean: mount/unmount symmetric, disconnect calls unmount, turbo:before-cache listener torn down alongside it, and the ?. on the async autoUpdate callback protects against unmount racing the pending computePosition Promise. animationName === "exit" filter matches the tw-animate-css keyframe (@keyframes exit), confirmed in the bundle.

fill-mode-forwards + pointer-events-none correctly handle the "invisible element still in layout post animate-out" case.

@cirdes cirdes merged commit 58243b0 into ruby-ui:main May 12, 2026
5 checks passed
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