ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Pin @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

Custom delays

Tune open_delay: and close_delay: (milliseconds; defaults 700 / 300) to make the tooltip appear and dismiss faster.

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.