Tooltip
Floating hint anchored to a trigger. Opens on hover + focus, closes on leave + Escape.
Installation
bin/rails g wabi:add tooltip
bin/importmap pin @zag-js/tooltip
bin/importmap pin @zag-js/vanillaPin @zag-js/tooltip and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs — bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
Helpful hint displayed on hover or focus.
render Components::UI::Tooltip.new do
render Components::UI::TooltipTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Hover me" }
render Components::UI::TooltipContent.new { "Helpful hint displayed on hover or focus." }
end
Custom delays
Tune open_delay: and close_delay: (milliseconds; defaults 700 / 300) to make the tooltip appear and dismiss faster.
Appears quickly, dismisses instantly.
render Components::UI::Tooltip.new(open_delay: 150, close_delay: 0) do
render Components::UI::TooltipTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Hover me" }
render Components::UI::TooltipContent.new { "Appears quickly, dismisses instantly." }
end
Source
app/components/ui/tooltip.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class Tooltip < Wabi::Base
def initialize(id: nil, open: false, open_delay: 700, close_delay: 300, portal: true, **attrs)
@id = id
@open = open
@open_delay = open_delay
@close_delay = close_delay
@portal = portal
@attrs = attrs
end
def view_template(&block)
div(
id: @id,
class: "inline-block",
data: {
controller: "wabi--tooltip",
"wabi--tooltip-open-value": @open.to_s,
"wabi--tooltip-open-delay-value": @open_delay.to_s,
"wabi--tooltip-close-delay-value": @close_delay.to_s,
"wabi--tooltip-portal-value": @portal.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/tooltip_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class TooltipTrigger < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Spread remaining caller attrs (e.g. aria-label for icon-only triggers)
# after extracting :class so callers can pass aria-label: "…" etc.
button(
type: "button",
data: { "wabi--tooltip-target": "trigger" },
class: merge_class(user_class),
**@attrs
) do
yield if block_given?
end
end
end
end
end
app/components/ui/tooltip_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class TooltipContent < Wabi::Base
variants do
base "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground shadow-md " \
"transition-opacity duration-150 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0 " \
"data-[state=closed]:pointer-events-none"
end
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
# Visibility via data-state + opacity transition rather than `hidden`,
# so fade-in / fade-out actually run instead of snapping. Controller
# mirrors `inert` on the content based on api.open.
div(
data: { "wabi--tooltip-target": "positioner" },
class: "z-50 pointer-events-none"
) do
div(
data: { "wabi--tooltip-target": "content" },
"data-state": "closed",
inert: true,
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
end
Accessibility
- role="tooltip" on the content panel; aria-describedby set on the trigger element.
- The tooltip shows on hover AND keyboard focus — pure-hover tooltips are inaccessible to keyboard users.
- Escape dismisses an open tooltip immediately.
- Delay timing (open/close) is configurable to balance information density vs. accidental triggers.