Joel Christiansen

Tippy.js Tooltips + Rails Stimulus

December 22, 2023
This walkthrough shows how to set up tooltips in a new Rails+Stimulus app using Tippy.js. This simple from-scratch example should be easy to customize, reuse, etc. 

Below, I'm going to walk through the 2 commits in this repo. The first commit sets up a simple Rails 7 app and the second implements a tooltip feature with some customization.

=====
Commit 1: Create New App
-------

Check versions:

❯ ruby -v
ruby 3.2.2 (2023-03-30 revision e51014f9c0) [x86_64-darwin22]

❯ rails -v
Rails 7.1.2

Create a new Rails app (details on flags here):

❯ rails new rails-stimulus-tooltips -j esbuild -d postgresql

We're using esbuild for JS bundling so we can use Yarn to install and use Tippy.js. You could easily do this with importmaps instead of esbuild, but I still default to esbuild because it offers more flexibility for interacting with JS packages.

After cding into the new directory, create a database with rails db:create, run bin/dev, and navigate to localhost:3000 in your browser. The Rails welcome screen should appear:

Rails Welcome Screen



=====
Commit 2: Tooltip Setup
-------


First let's set up a simple static view. Create a controller at app/controllers/static_controller.rb:

class StaticController < ApplicationController
end

Create a view at app/views/static/index.html.erb:

<div data-controller="tooltips">
  <span id="tooltip-1--trigger">Tooltip 1</span>
</div>

The operative parts here are the data-controller attribute and the span id of "tooltip-1--trigger." We'll use these to interact with Stimulus to display the tooltips.

Point the root route to the static#index action in config/routes.rb (Rails will render the index view by inference, so no need to define the action in the controller):

Rails.application.routes.draw do
  # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
  # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
  # Can be used by load balancers and uptime monitors to verify that the app is live.
  get "up" => "rails/health#show", as: :rails_health_check

  # Defines the root path route ("/")
  # root "posts#index"
  root "static#index"
end

At this point, refreshing the app in the browser should display the view (interactions won't work):

Static index view


To install Tippy.js, run yarn add tippy.js. You should see something like this:

❯ yarn add tippy.js
yarn add v1.9.2
[1/4] 🔍  Resolving packages...
[2/4] 🚚  Fetching packages...
[3/4] 🔗  Linking dependencies...
[4/4] 📃  Building fresh packages...
success Saved lockfile.
success Saved 2 new dependencies.
info Direct dependencies
└─ tippy.js@6.3.7
info All dependencies
├─ @popperjs/core@2.11.8
└─ tippy.js@6.3.7
✨  Done in 1.13s.

You should see Tippy.js in package.json:

  "dependencies": {
    "@hotwired/stimulus": "^3.2.2",
    "@hotwired/turbo-rails": "^8.0.0-beta.2",
    "esbuild": "^0.19.10",
    "tippy.js": "^6.3.7"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets"

You will also see Tippy.js and Popperjs added to yarn.lock. Popper is a dependency of Tippy.js. If you are interested in the future of Popper or floating elements in general, it's worth checking out the new Floating UI library that may eventually replace Popper.

The final step is setting up an actual tooltip, which Tippy.js makes very easy.

Start by renaming app/javascript/controllers/hello_controller.js to app/javascript/controllers/tooltips_controller.js and running stimulus:manifest:update to update the controller imports at app/javascript/controllers/index.js. index.js should look like this:

import { application } from "./application"

import TooltipsController from "./tooltips_controller"
application.register("tooltips", TooltipsController)

That brings us to the tooltip code, which I'll copy here and then explain:

import { Controller } from "@hotwired/stimulus";
import tippy from "tippy.js";

export default class extends Controller {
  connect() {
    var el = document.getElementById("tooltip-1--trigger")
    if (el) {
      const instance = tippy(el, {
        theme: 'info',
        hideOnClick: false,
        trigger: 'click',
        interactive: true,
        allowHTML: true,
        content: `
          <div class="tooltip"> \
            <div>Here is some info</div> \
            <div> \
              And <a href="https://www.theverge.com/2013/5/9/4316222/simcity-lead-designer-stone-librande-talks-about-building-game" target="_blank">links work too</a>! \
            </div> \
            <button class="close-tooltip">Close</button> \
          </div>
        `,
        onShow(instance) {
          const closeTooltipButtons = Array.from(instance.popper.querySelectorAll('.close-tooltip'));
          closeTooltipButtons.forEach(button => {
            button.addEventListener('click', function () {
              instance.hide();
            });
          });
        }
      })
    }
  }
}

First we're importing the Tippy.js library. Then, when the controller connects (i.e., when the index view loads), it creates a tooltip for the tooltip-1--trigger element with the specified properties and a custom function and theme.

We are setting the tooltip be triggered by a click instead of the default, which displays the tooltip on hover. We are setting the tooltip to not close when we click elsewhere on the screen. We are providing some HTML content for the actual tooltip. And, we are setting the tooltip to be interactive and allow HTML inside. These are all non-standard tooltip behaviors, but I added them as a means to show ways you might get creative with using tooltips.

The onShow function sets up the mechanics for using our close button to close the modal. I wrote this function assuming there might be multiple tooltips in this controller. YMMV and this part should be customized for your use case/setup.

Finally, we are setting a theme called info. This is a custom theme, meaning that it doesn't trigger anything in the Tippy library itself (Tippy offers a few pre-baked themes that I won't cover in this walkthrough). Instead, all it does it add a custom data attribute to the tooltip so we can target it with CSS, which I added in app/assets/stylesheets/application.css:

/*
 * This is a manifest file that'll be compiled into application.css, which will include all the files
 * listed below.
 *
 * Any CSS (and SCSS, if configured) file within this directory, lib/assets/stylesheets, or any plugin's
 * vendor/assets/stylesheets directory can be referenced here using a relative path.
 *
 * You're free to add application-wide styles to this file and they'll appear at the bottom of the
 * compiled file so the styles you add here take precedence over styles defined in any other CSS
 * files in this directory. Styles in this file should be added after the last require_* statement.
 * It is generally better to create a new file per style scope.
 *
 *= require_tree .
 *= require_self
 */

#tooltip-1--trigger:hover {
  cursor: pointer;
  text-decoration: underline;
}

.tippy-box[data-theme~='info'] {
  background-color: #c5c5c5;
  color: #555555;
  padding: 5px 8px;
  border-radius: 2px;
}

.tippy-box #tooltip-1 {
  width: 200px;
  height: 100px;
}

button.close-tooltip {
  margin-top: 10px;
}

This CSS adds an underline to the tooltip trigger on hover. It also adds styling to .tippy-box elements with the data-theme attribute equal to "info." This is how the custom info theme is implemented in CSS, super straightforward. I also added some basic style to .tippy-box #tooltip-1, targeting the specific HTML ID that I included in the Stimulus controller. Finally, I added a top margin to the close button to add some visual spacing.

When you click on "Tooltip 1," the tooltip will now appear with working link and it will close only when you press the "Close" button (not when you click elsewhere on the screen):
Tooltip

That's all. Very rudimentary example, but easy to customize, refactor, etc. from here.