[Feature] Tooltip: match shadcn (Base UI + vega)
Brings Tooltip in line with shadcn (Base UI + vega), following the shadcn audit run on 2026-11-04. It also fixes the follow-up from #644: with two sidebars on one page, a collapsed sidebar's tooltips stayed hidden while the other sidebar was expanded.
What changes
- TooltipContent is a
role="tooltip"popover="manual"in the top layer, with vega's classes (bg-foreground text-background,text-xs,max-w-xs,inline-flex gap-1.5, kbd rules) and the arrow, placed by Floating UI'sarrow(). The controller now addsflip()and Base UI's--transform-origin. - The content copy goes right after the trigger instead of into
<body>, so it stays inside its container. SidebarMenuButton hides it from its own sidebar's state ([[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden) and drops thebody:has(...)rule that any expanded sidebar matched. - TooltipTrigger is an
inline-flex w-fitwrapper, so the hit area and the anchor are the button (before, the whole row opened it and the tooltip was centered on the row). It no longer emits a strayvariant="outline"attribute. - Keyboard focus opens it (
focusin, focus-visible only, as Base UI). Escape closes it, and the pointer can move onto it without closing it (WCAG 1.4.13). The focusable trigger getsaria-describedby, as the APG tooltip pattern asks; Base UI leaves that out. SidebarMenuButton skips it when the tooltip only repeats the label. - New options:
Tooltip(open:, disabled:)plus aruby-ui--tooltip:changeevent,TooltipTrigger(delay: 0, close_delay: 0, close_on_click: true)andTooltipContent(side:, side_offset: 4, align:, align_offset: 0)with logical:inline_start/:inline_end.placement:keeps working as a fallback forside:. Every part has adata-slot. - Adds the audit map (with
baseline tooltip 8a353667) and fixtures, updates the docs and rebuilds the MCP registry.
A Codex review caught five issues, all fixed in the second commit: Escape on a tooltip hidden by CSS no longer swallows the key meant for the Sheet, hover state resets on reconnect, a press during the open delay cancels it, align_offset: works with align: :center, and the arrow points the right way in RTL.
Screenshots




Measured in headed Chromium at 1280×800 with the same script on both fixtures, RubyUI and shadcn now give the same results: opens on Tab, closes on Escape, stays open with the pointer on it, popup centered on the button (x=254 on both), flips below at the top edge (y=42).
Follow-ups
- PopoverContent has the same
align_offset:gap withalign: :center(it passes onlyalignmentAxis). Same one-line fix, left for a Popover PR. - Bubble's docs and the chat theme put
Textinside TooltipContent, andText's own size class overrides the tooltip'stext-xs. Their examples should use a plainpor string. - Base UI's Trigger defaults
delayto 600 ms; RubyUI defaults to 0 because shadcn's TooltipProvider sets 0. Revisit if RubyUI gets a provider.
Test steps
cd gem && bundle exec rake,cd mcp && bundle exec rake test,cd audit && bundle exec rake,cd docs && bin/rails test./docs/tooltip: hover each example, Tab to a trigger (opens), press Escape (closes), move the pointer onto the tooltip (stays open), try the Sides row near the top of the window (flips).- A page with two sidebars, one
collapsible: :iconcollapsed and one expanded: hovering a collapsed button shows its tooltip; the expanded sidebar's buttons show none.
Audit checklist, main
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 1998c5e8 shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment Behavior checks in Chrome (chrome-devtools MCP, isolated context) and headed Playwright at 1280×800; placement measured with body padding 200px on both sides.
✅ Theme neutral tokens match shadcn
Tooltip (ref: bases/base/ui/tooltip.tsx + style-vega.css @ 8a353667)
⏸ Look TooltipContent: selector matched 0/1 visible elements in focus/light; 1/0 visible elements in after-esc/light; 0/1 visible elements in focus/dark; 1/0 visible elements in after-esc/dark — Settled in Chrome: RubyUI never opens on keyboard focus and stays open after Esc. Graded on the Behavior lines. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline. Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period). — Requested: run `baseline tooltip 8a3536678a528cc012b9e62484290e79d0be410f` in the fix PR, and commit audit/component_map/tooltip.yml + audit/fixtures/tooltip/ (created locally for this audit).
❌ Look background-color: oklch(0.205 0 0); background-color: oklch(1 0 0); border-bottom-width: 1px; border-left-width: 1px; border-right-width: 1px; border-top-width: 1px; box-shadow: rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.1) 0px 2px …; color: oklch(0.145 0 0); color: oklch(0.985 0 0); column-gap: normal; display: block; gap: normal; row-gap: normal
vega: background-color: oklch(0.145 0 0); background-color: oklch(0.985 0 0); border-bottom-width: 0px; border-left-width: 0px; border-right-width: 0px; border-top-width: 0px; box-shadow: none; color: oklch(0.145 0 0); color: oklch(1 0 0); column-gap: 6px; display: inline-flex; gap: 6px; row-gap: 6px
TooltipContent — open/light, open/dark — Root cause: RubyUI uses the popover surface (bg-popover text-popover-foreground border shadow-md, block); vega's tooltip is inverted (bg-foreground text-background, no border, no shadow) and inline-flex items-center gap-1.5. Font size is split out below. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
↳ computed styles (shadcn-audit collect)
⏸ Look properties: font-family, font-size, height, line-height, width
TooltipTrigger, TooltipContent — explained by environment diff: font — Only font-family is the environment. font-size/line-height (and the width/height they drive) are the component: text-sm vs text-xs, graded on its own line. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
⏸ ARIA TooltipContent: selector matched 0/1 visible elements in focus/light; 1/0 visible elements in after-esc/light; 0/1 visible elements in focus/dark; 1/0 visible elements in after-esc/dark — Settled in Chrome: RubyUI never opens on keyboard focus and stays open after Esc. Graded on the Behavior lines. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline. Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
✅ ARIA TooltipTrigger: accessibility tree matches
↳ accessibility tree (shadcn-audit collect)
❌ Look TooltipContent text-sm (14px / 20px line-height, 34px tall)
vega: text-xs (12px / 16px, 28px tall)
Measured in Chrome per element (collect had filed it under the font environment diff). — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
↳ style-vega.css:1381
❌ Look TooltipContent max-w-[calc(100vw-2rem)] text-balance break-words
vega: max-w-xs (20rem)
Long content wraps at almost the viewport width in RubyUI, at 320px in shadcn. — Requested: vega classes on TooltipContent (bg-foreground text-background, no border/shadow, inline-flex items-center gap-1.5, rounded-md px-3 py-1.5 text-xs, max-w-xs, kbd rules).
↳ bases/base/ui/tooltip.tsx:52
❌ Look no arrow
vega: TooltipPrimitive.Arrow: size-2.5 rotate-45 rounded-[2px] bg-foreground, -bottom-2.5 on side top (14×14 box measured)
shadcn draws a small rotated square pointing at the trigger. — Requested: add the arrow (vega classes per side), positioned with Floating UI's arrow() middleware.
↳ bases/base/ui/tooltip.tsx:58 + style-vega.css:1388
❌ HTML TooltipTrigger = <div> wrapper (display:block) around the Button, with a stray variant="outline" attribute and aria-describedby on the div
shadcn: Trigger renders the Button itself (render={<Button />}) with data-slot=tooltip-trigger
variant: :outline in TooltipTrigger#default_attrs is emitted as an HTML attribute. The block wrapper is the root cause of the hit-area and placement lines below. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
↳ bases/base/ui/tooltip.tsx:24
❌ HTML no data-slot on Tooltip, TooltipTrigger, TooltipContent
shadcn: data-slot=tooltip / tooltip-trigger / tooltip-content
Tooltip root is a div.group carrying the Stimulus controller (needed in RubyUI; Base UI Root renders nothing). <template> + clone into <body> stands in for the Portal. — Requested: data-slot tooltip / tooltip-trigger / tooltip-content (content keeps it in the rendered node).
↳ bases/base/ui/tooltip.tsx:20
❌ Props Tooltip: placement: only
shadcn: Root: open, defaultOpen, onOpenChange, disabled (Base UI Tooltip.Root)
disabled is what the Sidebar needs (shadcn passes hidden per sidebar). Provider (delay, default 0 in shadcn) has no RubyUI part; RubyUI opens immediately, which matches. — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
↳ @base-ui/react 1.9.0 tooltip/root/TooltipRoot.d.ts:21-56
❌ Props TooltipTrigger: no kwargs
shadcn: delay (600), closeDelay (0), closeOnClick (true)
closeOnClick: Base UI closes the tooltip when the trigger is pressed; RubyUI keeps it open (the fixture turns it off on the shadcn side so collect can reach open). — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
↳ @base-ui/react 1.9.0 tooltip/trigger/TooltipTrigger.d.ts:31-50
❌ Props TooltipContent: no kwargs (offset fixed at 4, align always center); placement: lives on Tooltip
shadcn: side (top), sideOffset (4), align (center), alignOffset (0)
side → Tooltip(placement:) works for top/right/bottom/left; inline-start/inline-end (logical sides) are not supported. — Requested: disabled:, open: (initial), delay:/close_delay: (default 0), close_on_click: (default true), side:/side_offset:/align:/align_offset:, ruby-ui--tooltip:change event; placement: stays as an alias of side:.
↳ bases/base/ui/tooltip.tsx:29-32
⚠️ Behavior Keyboard focus never opens the tooltip
shadcn: Opens on focus (data-instant=focus)
focus->/blur-> actions sit on the wrapper <div>; focus doesn't bubble, so focusing the Button inside never fires show(). Keyboard users never see it. Checked with Tab in Chrome. (SidebarMenuButton puts the actions on the button itself, so the sidebar tooltip does open on focus.) — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
↳ bases/base/ui/tooltip.tsx:24
⚠️ Behavior Esc leaves it open
shadcn: Esc closes it
Checked in Chrome and headed Playwright. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#keyboard_interaction
⚠️ Behavior Not hoverable: pointer-events-none and mouseleave on the trigger hide it when the pointer moves onto it
shadcn: Pointer can move onto the popup and it stays open
Headed Playwright: pointer moved from trigger into the popup → RubyUI closed, shadcn stayed open. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
⚠️ Behavior Hit area and anchor = full-width wrapper: hovering at x=900px on the trigger's row opens it; tooltip centered at x=640 while the button's center is x=254 (386px off)
shadcn: Hit area and anchor = the button; popup centered on it (254 vs 254), 4px gap
Fixture in a block container at 1280×800, body padding 200px. In a flex row the wrapper shrinks to the button, so docs examples may hide it. — Requested: wrapper becomes inline-flex w-fit with data-slot=tooltip-trigger; focusin/focusout instead of focus/blur; anchor and aria-describedby on the focusable child; drop variant: :outline.
↳ bases/base/ui/tooltip.tsx:24
⚠️ Behavior No flip: trigger at the top edge puts the tooltip at y=-38 (off-screen)
shadcn: Base UI flips to bottom (data-side=bottom) when there's no room on top
Controller middleware is offset(4) + shift() only; add flip(). — Requested: add flip() and write the final side to data-side so slide/arrow follow it.
↳ bases/base/ui/tooltip.tsx:45
⚠️ Behavior Two sidebars on one page: hovering a collapsed sidebar's menu button gives its tooltip display:none when another sidebar is expanded
shadcn: Each tooltip is hidden only by its own sidebar's state (hidden={state !== "collapsed" || isMobile})
Follow-up from #644, reproduced with a temporary fixture (one collapsed left sidebar, one expanded right sidebar): with both, the tooltip is open but display:none; removing the expanded sidebar shows it. Cause: the clone lives in <body>, so SidebarMenuButton hides it with body:has([data-slot=sidebar][data-state=expanded]), which any expanded sidebar matches. — Requested: move TooltipContent to popover="manual" (top layer, node stays in place); SidebarMenuButton hides its tooltip from its own sidebar's state via CSS and drops body:has(...).
↳ bases/base/ui/sidebar.tsx:555
✅ Behavior Opens instantly on hover (no delay)
Same as shadcn with its Provider.
↳ bases/base/ui/tooltip.tsx:7
⚠️ ARIA aria-describedby set on the wrapper <div>, not on the focusable Button; popup has no role
shadcn: No aria-describedby, no role=tooltip on the popup (generic)
Neither side announces the tooltip text as the button's description. RubyUI's attribute lands on an element AT never focuses, so it has no effect. — Requested: follow APG — role="tooltip" on the content and aria-describedby on the focusable trigger (diverges from shadcn, follows the spec).
↳ https://www.w3.org/TR/wai-aria-1.2/#aria-describedby
⚠️ Spec Popup without role=tooltip; describedby on a non-focusable ancestor
shadcn: Popup without role=tooltip; trigger without aria-describedby
APG recommendation (not normative): tooltip container has role tooltip and the trigger references it with aria-describedby. Base UI leaves this out on purpose (it asks for an aria-label on icon triggers). RubyUI copies the miss. — Requested: follow APG — role="tooltip" on the content and aria-describedby on the focusable trigger (diverges from shadcn, follows the spec).
↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
⚠️ Spec Not dismissable with Esc, not hoverable, not shown on focus
shadcn: Dismissable, hoverable, shown on focus
WCAG 2.2 SC 1.4.13 (AA): content shown on hover or focus must be dismissible without moving pointer/focus and hoverable. RubyUI alone misses it; WCAG is outside the weights table, so ⚠️ rather than ❌. APG also lists Escape: dismisses the tooltip. — Requested: Esc on document closes while open; remove pointer-events-none; close only when the pointer leaves both trigger and content (short grace period).
↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
✅ Animation Enter: animation 'enter' 0.15s, fade-in-0 zoom-in-95 + slide-in by placement
Measured with getComputedStyle on both sides at open.
↳ style-vega.css:1381
⚠️ Animation Zoom from the popup center (no transform-origin)
shadcn: origin-(--transform-origin): zooms out of the trigger side
Visible as a subtle scale from the middle instead of from the arrow. — Requested: controller sets --transform-origin from the final side/arrow; content uses origin-(--transform-origin).
↳ bases/base/ui/tooltip.tsx:52
❌ Native JS clones a <template> into <body> and toggles data-state; z-50 for stacking
shadcn: Base UI Portal + Positioner
Candidate for popover="manual" (Baseline): top layer without moving the node, so the content stays inside the sidebar and its own data-state can hide it (fixes the two-sidebar line). Floating UI still positions it; Esc and hover/focus wiring stay in the controller. pt-BR MDN page is 404. — Requested: move TooltipContent to popover="manual" (top layer, node stays in place); SidebarMenuButton hides its tooltip from its own sidebar's state via CSS and drops body:has(...).
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
Audit checklist, this PR
shadcn audit ruby-ui/ruby_ui
base 1998c5e8 head 440ce9ab shadcn 8a353667
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (light)
⚠️ Environment font: "Inter Variable", "system-ui", -apple-system, "system-ui", "Segoe UI", Roboto, "Helvetica Neue", Arial, "sans-serif" vs "Inter Variable", sans-serif (dark)
⚠️ Environment Behavior checks in headed Playwright at 1280×800; placement measured with body padding 200px on both sides.
✅ Theme neutral tokens match shadcn
Tooltip (ref: bases/base/ui/tooltip.tsx + style-vega.css @ 8a353667)
✅ Look display: flex
TooltipTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — Override: renders the same. RubyUI's wrapper is inline-flex, so its Button child is blockified (inline-flex → flex); the content is position: fixed in the top layer, which blockifies it too. shadcn's Button and popup sit in block parents. Same box, size and position measured in Chrome (popup centered at x=254 on both).
↳ computed styles (shadcn-audit collect)
✅ Look display: flex
TooltipContent — open/light, focus/light, open/dark, focus/dark — Override: renders the same. RubyUI's wrapper is inline-flex, so its Button child is blockified (inline-flex → flex); the content is position: fixed in the top layer, which blockifies it too. shadcn's Button and popup sit in block parents. Same box, size and position measured in Chrome (popup centered at x=254 on both).
↳ computed styles (shadcn-audit collect)
⏸ Look properties: font-family
TooltipTrigger, TooltipContent — explained by environment diff: font
✅ ARIA aria-describedby: Add to library — follows spec
TooltipTrigger — closed/light, open/light, focus/light, after-esc/light, closed/dark, open/dark, focus/dark, after-esc/dark — Diverges from shadcn, follows the spec: APG tooltip pattern asks for role=tooltip on the popup and aria-describedby on the trigger; Base UI leaves both out.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
✅ ARIA snapshot: only here: - tooltip "Add to library": | - paragraph: Add to library — follows spec
TooltipContent — open/light, focus/light, open/dark, focus/dark — Diverges from shadcn, follows the spec: APG tooltip pattern asks for role=tooltip on the popup and aria-describedby on the trigger; Base UI leaves both out.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
✅ Look TooltipContent text-xs (12px / 16px), max-w-xs, bg-foreground text-background, no border/shadow, arrow size-2.5 rotate-45
Computed styles match in collect; arrow screenshot matches pixel for pixel at keyboard focus.
↳ style-vega.css:1380-1393
✅ HTML TooltipTrigger: inline-flex w-fit wrapper with data-slot=tooltip-trigger, no stray variant attribute; data-slot tooltip / tooltip-content / tooltip-arrow
The wrapper stays (Phlex has no render prop), but it shrinks to the Button, so hit area and anchor are the Button.
↳ bases/base/ui/tooltip.tsx:20-58
✅ Props Tooltip: open:, disabled:, change event; TooltipTrigger: delay: (0), close_delay: (0), close_on_click: (true); TooltipContent: side:, side_offset: (4), align:, align_offset: (0)
open: is the initial state (defaultOpen); the open value attribute and ruby-ui--tooltip:change cover controlled use. delay defaults to 0 as shadcn's TooltipProvider. placement: kept as an alias (rubyui_only).
↳ bases/base/ui/tooltip.tsx:29-32 + @base-ui/react 1.9.0 tooltip/*.d.ts
✅ Behavior Opens on keyboard focus, Esc closes, hoverable, hit area = button (pointer at x=900 doesn't open), centered on the button (254 vs 254), flips to bottom at the top edge (y=42, data-side=bottom)
Headed Playwright, same script on both sides (verify-after.mjs).
↳ https://www.w3.org/TR/WCAG22/#content-on-hover-or-focus
✅ Behavior Two sidebars on one page: the collapsed sidebar's tooltip shows (display:flex) while the other sidebar is expanded; the expanded sidebar's own tooltip stays hidden
Fixes the #644 follow-up. The content sits after the button inside its sidebar, hidden by [[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden.
↳ bases/base/ui/sidebar.tsx:555
✅ Behavior Opens instantly on hover (delay 0)
↳ bases/base/ui/tooltip.tsx:7
✅ Spec role=tooltip on the popup, aria-describedby on the focusable trigger; Esc, hover and focus per WCAG 1.4.13 — follows spec
Diverges from shadcn, follows the spec (APG recommendation). SidebarMenuButton skips aria-describedby when the tooltip repeats its label.
↳ https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/#roles_states_properties
✅ Animation Enter 'enter' 0.15s fade/zoom/slide by data-side; zoom from --transform-origin (trigger side)
↳ style-vega.css:1381 + bases/base/ui/tooltip.tsx:52
✅ Native popover="manual": top layer, node stays next to the trigger; Floating UI (offset, flip, shift, arrow) places it — native
Esc and hover/focus wiring stay in the controller (popover="auto" would light-dismiss on any click and skip the exit animation). Nothing shadcn delivers is lost.
↳ https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/popover
🤖 Generated with Claude Code
Summary by cubic
Brings Tooltip in line with shadcn (Base UI + vega), adding keyboard focus, Escape dismissal, and hover support per WCAG 1.4.13, plus new placement and interaction options. Also fixes a follow-up from #644 where a collapsed sidebar's tooltips stayed hidden when another sidebar was expanded.
Key changes
- TooltipContent is now a
role="tooltip"popover="manual"in the top layer with vega styling and an arrow; it sits right after the trigger, so it stays inside its container. - Adds
open:,disabled:,delay:,close_delay:,close_on_click:,side:,side_offset:,align:, andalign_offset:options (placement:stays as a fallback). - The tooltip now opens on keyboard focus, closes on Escape, and stays open while the pointer is over it; the focusable trigger gets
aria-describedby. open:anddisabled:are coerced to booleans sonildoesn't render an empty Stimulus value, and an open set while disabled takes effect once re-enabled.- SidebarMenuButton hides its tooltip from only its own sidebar's state, fixing tooltips staying hidden when another sidebar is expanded.
- Adds the audit map, fixtures, docs updates, and rebuilds the MCP registry.