Blocks

Tooltip

A short label that appears on hover or keyboard focus. The panel renders in the browser's top layer, so no container can clip it.

Installation

bin/rails g shadcnrb:component tooltip

Example

The block is the panel. t.trigger marks what the panel is attached to — you write that element yourself with any helper, and it can sit anywhere in the block since it's hoisted to the top of the wrapper. sui.hover_card takes the same shape.

<%= sui.tooltip do |t| %>
  <%= t.trigger { sui.button "Save", variant: :outline } %>
  Add to library
<% end %>

Attaching to any helper

Give t.trigger a block and your markup is used verbatim — every helper keeps its own signature. The tooltip never intercepts it: hover is bound on the wrapper, so button_to keeps its CSRF form and links keep their paths.

Docs
<%= sui.tooltip do |t| %>
  <%= t.trigger { sui.link_to "Docs", "https://ui.shadcn.com", target: "_blank" } %>
  Opens in a new tab
<% end %>

<%= sui.tooltip side: :bottom do |t| %>
  <%= t.trigger do %>
    <%= sui.button_to "Delete", destroy_demo_chart_path(1), method: :delete,
          variant: :destructive, size: :sm %>
  <% end %>
  Delete this chart
<% end %>

<%= sui.tooltip do |t| %>
  <%= t.trigger { sui.button variant: :ghost, size: :icon, icon: :ellipsis } %>
  Settings
<% end %>

Sides

side: takes :top (default), :right, :bottom, :left. When the declared side doesn't fit the viewport the panel flips to the opposite one, and it slides along the free axis to stay in view — scroll this page with a tooltip open to watch it.

<% %i[top right bottom left].each do |side| %>
  <%= sui.tooltip side: side do |t| %>
    <%= t.trigger { sui.button side.to_s.titleize, variant: :outline } %>
    On the <%= side %>
  <% end %>
<% end %>

Alignment

align: shifts the panel along the free axis — :start, :center (default), :end. Handy when the trigger sits at a container edge.

<% %i[start center end].each do |align| %>
  <%= sui.tooltip side: :bottom, align: align do |t| %>
    <%= t.trigger { sui.button align.to_s.titleize, variant: :outline } %>
    Aligned <%= align %>
  <% end %>
<% end %>

Delays

Milliseconds before the panel opens and after the pointer leaves. Both default to 0 — shadcn's TooltipProvider default, minus the provider.

<%= sui.tooltip delay: 500, close_delay: 200 do |t| %>
  <%= t.trigger { sui.button "Patient", variant: :secondary } %>
  Waited half a second
<% end %>

Markup panels

The panel body is ordinary ERB, so anything composes inside it. Style the panel itself through content: — its own option hash, the same way button_to takes form:.

<%= sui.tooltip content: { class: "w-44" } do |t| %>
  <%= t.trigger { sui.button "Save" } %>
  <span class="flex items-center justify-between gap-2">
    Save changes
    <kbd class="rounded border border-background/30 px-1 font-mono text-[10px]">⌘S</kbd>
  </span>
<% end %>

<%= sui.tooltip side: :bottom do |t| %>
  <%= t.trigger { sui.button "Two lines", variant: :outline } %>
  <span class="block font-medium">Rate limited</span>
  <span class="block text-background/70">Try again in about a minute.</span>
<% end %>

Long labels

The panel is w-fit max-w-xs, same as upstream: it hugs a short label and wraps past 20rem.

<%= sui.tooltip do |t| %>
  <%= t.trigger { sui.button "Long label", variant: :outline } %>
  Exports the current view as CSV. Large exports are emailed to you when they finish.
<% end %>

Disabled trigger

Hover is bound on the wrapper, not the trigger, so a disabled button still shows its tooltip — no <span> wrapper needed.

<%= sui.tooltip do |t| %>
  <%= t.trigger { sui.button "Publish", variant: :outline, disabled: true } %>
  Add a title before publishing
<% end %>

Clipping

The panel is a native popover="manual" element shown in the browser's top layer, so ancestors with overflow: hidden (or scrolling containers, transforms, stacking contexts) can't clip or cover it. This trigger sits in a cramped overflow-hidden box; the tooltip doesn't care.

<div class="h-12 w-40 overflow-hidden rounded-md border border-dashed p-2">
  <%= sui.tooltip do |t| %>
    <%= t.trigger { sui.button "Boxed in", variant: :outline, size: :sm } %>
    Escaped the box
  <% end %>
</div>

API

MethodArgsDefaultDescription
sui.tooltipside:, align:, arrow:, delay:, close_delay:, content:, **opts:top / :center / true / 0 / 0Wrapper; the block body is the panel. Mounts the shared shadcnrb--anchored--component controller and owns the hover/focus wiring. **opts land on the wrapper, content: on the panel.
t.trigger&blockMarks which part of the block is the trigger; everything else is the panel. Your markup, verbatim — the component renders no button of its own, which is why it has no dependencies. Hoisted to the top of the wrapper, so its position in the block doesn't matter.

aria-describedby is wired on connect to the hoisted trigger's first element. Tag your own element data-slot="tooltip-trigger" to point it somewhere else — worth doing if the trigger block renders several siblings.

Divergences

No TooltipProvider — delays are per-tooltip. No TooltipContent part either; the block body is the panel. Positioning is @floating-ui/dom, vendored via importmap (no npm), and the panel renders in the browser's top layer instead of a portal. The sideOffset / alignOffset props are baked in at 4px.