View as Markdown

Tooltip

A Stimulus behavior that shows a tooltip from a content value — or straight from an element's native title. Powered by Floating UI: the tooltip is rendered outside the trigger (appended to <body>), so it's never clipped by an overflow ancestor, and it flips and shifts to stay in view. Put it right on the trigger — it opens on hover and keyboard focus by default — and style the injected .tooltip once, globally.

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <button type="button" data-controller="tooltip" title="Thanks for hovering!" class="">
        Hover me
    </button>
</div>

Installation

php bin/console ux:install tooltip --kit common

Install the following front-end dependencies:

npm install @floating-ui/dom
# ...or with Symfony AssetMapper
php bin/console importmap:require @floating-ui/dom

Copy the following file(s) into your app:

assets/controllers/tooltip_controller.js
import { Controller } from '@hotwired/stimulus';
import { computePosition, autoUpdate, offset, flip, shift, arrow } from '@floating-ui/dom';

let sequence = 0;

const OPPOSITE_SIDE = { top: 'bottom', right: 'left', bottom: 'top', left: 'right' };

/**
 * @value  content    The text shown inside the tooltip; falls back to the element's `title` when omitted. Any `title` is removed regardless, so the browser's native tooltip never competes with this one.
 * @value  placement  Preferred side of the trigger: `top` (default), `bottom`, `left` or `right`. Flips and shifts to stay in view.
 * @value  trigger    How the tooltip opens: `hover` (on hover and keyboard focus, the default), `click` (toggle on click), or `manual` (you drive `show`/`hide` yourself). Defaults to `hover`.
 * @value  autoHide   Delay in milliseconds after which a shown tooltip hides itself; when 0, it stays until `hide`. Defaults to 0.
 * @action show       Shows the tooltip, scheduling an automatic hide when `autoHide` is set.
 * @action hide       Hides the tooltip.
 * @action toggle     Shows the tooltip when hidden, hides it when shown.
 */
export default class extends Controller {
    static values = {
        content: String,
        placement: { type: String, default: 'top' },
        trigger: { type: String, default: 'hover' },
        autoHide: Number,
    };

    #tooltip;
    #timeout;
    #triggers; // AbortController for the auto-wired hover/click listeners
    #cleanup; // stops Floating UI's autoUpdate while the tooltip is hidden

    connect() {
        // Remove a tooltip a previous instance left in <body> (e.g. after a Turbo restore).
        const previous = this.element.getAttribute('aria-describedby');
        if (previous) {
            document.getElementById(previous)?.remove();
        }

        this.#tooltip = this.#build();
        // The tooltip lives in <body>, so associate it with the trigger by id.
        this.element.setAttribute('aria-describedby', this.#tooltip.id);
        this.#wire();
    }

    disconnect() {
        this.#triggers?.abort();
        this.#cleanup?.();
        this.#tooltip.remove();
    }

    show() {
        clearTimeout(this.#timeout);
        this.#tooltip.hidden = false;
        // Keep the tooltip anchored while visible: reposition on scroll, resize and layout shifts.
        this.#cleanup = autoUpdate(this.element, this.#tooltip, () => this.#position());

        if (this.autoHideValue) {
            this.#timeout = setTimeout(() => this.hide(), this.autoHideValue);
        }
    }

    hide() {
        clearTimeout(this.#timeout);
        this.#cleanup?.();
        this.#cleanup = null;
        this.#tooltip.hidden = true;
    }

    toggle() {
        if (this.#tooltip.hidden) {
            this.show();
            return;
        }

        this.hide();
    }

    #build() {
        const tooltip = document.createElement('span');
        tooltip.id = `tooltip-${++sequence}`;
        tooltip.className = 'tooltip';
        tooltip.setAttribute('role', 'tooltip');
        tooltip.hidden = true;

        const arrowElement = document.createElement('span');
        arrowElement.className = 'tooltip-arrow';

        tooltip.append(this.#content(), arrowElement);
        document.body.appendChild(tooltip);

        return tooltip;
    }

    #position() {
        const arrowElement = this.#tooltip.querySelector('.tooltip-arrow');

        computePosition(this.element, this.#tooltip, {
            placement: this.placementValue,
            middleware: [offset(8), flip(), shift({ padding: 8 }), arrow({ element: arrowElement })],
        }).then(({ x, y, placement, middlewareData }) => {
            Object.assign(this.#tooltip.style, { left: `${x}px`, top: `${y}px` });

            const { x: arrowX, y: arrowY } = middlewareData.arrow;
            const staticSide = OPPOSITE_SIDE[placement.split('-')[0]];
            Object.assign(arrowElement.style, {
                left: null != arrowX ? `${arrowX}px` : '',
                top: null != arrowY ? `${arrowY}px` : '',
                right: '',
                bottom: '',
                [staticSide]: '-4px',
            });
        });
    }

    #content() {
        // Always remove a `title` (even when `content` is set) so the browser's native tooltip never
        // competes with ours; fall back to its text only when no `content` value is given.
        this.#stashTitle();

        if (this.hasContentValue) {
            return this.contentValue;
        }

        return this.element.getAttribute('data-tooltip-original-title') ?? '';
    }

    // Move a native `title` into a data attribute the first time we see it: the browser's own tooltip
    // stops showing, and the text survives a Turbo snapshot/restore (a removed `title` would not).
    #stashTitle() {
        const title = this.element.getAttribute('title');
        if (null === title) {
            return;
        }

        this.element.removeAttribute('title');
        this.element.setAttribute('data-tooltip-original-title', title);
    }

    // Standard hover (plus keyboard focus) is the default, so the common case needs no `data-action`.
    #wire() {
        if ('manual' === this.triggerValue) {
            return;
        }

        this.#triggers = new AbortController();
        const options = { signal: this.#triggers.signal };

        if ('click' === this.triggerValue) {
            this.element.addEventListener('click', () => this.toggle(), options);

            return;
        }

        this.element.addEventListener('mouseenter', () => this.show(), options);
        this.element.addEventListener('mouseleave', () => this.hide(), options);
        this.element.addEventListener('focusin', () => this.show(), options);
        this.element.addEventListener('focusout', () => this.hide(), options);
    }
}

Usage

Style every tooltip once, in your global stylesheet — Floating UI sets the position and the arrow's offsets, so you only own the look:

.tooltip {
    position: absolute;
    top: 0;
    left: 0;
    width: max-content;
    border-radius: 0.25rem;
    background: #1f2937;
    color: #fff;
    padding: 0.25rem 0.5rem;
    font-size: 0.75rem;
    pointer-events: none;
    z-index: 50;
}
.tooltip-arrow {
    position: absolute;
    width: 8px;
    height: 8px;
    background: inherit;
    transform: rotate(45deg);
}

Then put the controller on the trigger with a content value — hover and focus are wired for you:

<button type="button" data-controller="tooltip" data-tooltip-content-value="Tooltip content">
    Hover me
</button>

Set placement to hint a side (top by default, bottom, left, or right) — Floating UI flips and shifts from there to keep it on screen. Set trigger to click to toggle on click, or manual to drive show/hide yourself (for example from another controller's event). The controller injects the .tooltip into <body> and points the trigger's aria-describedby at it.

As a shortcut — the form shown at the top — drop the controller onto an element that already has a title and skip the content value: the controller adopts the title text and removes the attribute so the browser's native tooltip is suppressed.

Examples

Placement

Set placement to top (default), bottom, left, or right. It's a preference — Floating UI flips to the opposite side and shifts along the axis whenever the trigger is too close to an edge:

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <button type="button" data-controller="tooltip" data-tooltip-content-value="Top" data-tooltip-placement-value="top" class="">Top</button>
    <button type="button" data-controller="tooltip" data-tooltip-content-value="Bottom" data-tooltip-placement-value="bottom" class="">Bottom</button>
    <button type="button" data-controller="tooltip" data-tooltip-content-value="Left" data-tooltip-placement-value="left" class="">Left</button>
    <button type="button" data-controller="tooltip" data-tooltip-content-value="Right" data-tooltip-placement-value="right" class="">Right</button>
</div>

Click to Toggle

Set trigger to click for a tooltip that stays open until the next click:

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <button type="button" data-controller="tooltip" data-tooltip-content-value="Click again to close" data-tooltip-placement-value="bottom" data-tooltip-trigger-value="click" class="">
        Click me
    </button>
</div>

Auto-hide

Set autoHide (milliseconds) so a shown tooltip dismisses itself — handy when it's opened programmatically rather than by a hover you can leave:

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <button type="button" data-controller="tooltip" data-tooltip-content-value="I'll disappear shortly…" data-tooltip-placement-value="bottom" data-tooltip-trigger-value="click" data-tooltip-auto-hide-value="2000" class="">
        Show for 2s
    </button>
</div>

Manual Control

With trigger set to manual, nothing is wired automatically — you drive show, hide, and toggle yourself, from your own actions or another controller's event. The tooltip anchors to the controller's element:

100%
Loading...
<style>
    .tooltip { position: absolute; top: 0; left: 0; width: max-content; border-radius: .25rem; background: #4c1d95; color: #fff; padding: .25rem .5rem; font-size: .75rem; font-weight: 500; pointer-events: none; z-index: 50; }
    .tooltip-arrow { position: absolute; width: 8px; height: 8px; background: inherit; transform: rotate(45deg); }
</style>
<div class="">
    <div data-controller="tooltip" data-tooltip-content-value="Toggled by hand" data-tooltip-trigger-value="manual" class="">
        <button type="button" data-action="tooltip#show" class="">Show</button>
        <button type="button" data-action="tooltip#hide" class="">Hide</button>
    </div>
</div>

API Reference

data-controller="tooltip"

Value Type Default
data-tooltip-content-value 
String -
data-tooltip-placement-value 
String 'top'
data-tooltip-trigger-value 
String 'hover'
data-tooltip-auto-hide-value 
Number -
Action Description
show Shows the tooltip, scheduling an automatic hide when autoHide is set.
hide Hides the tooltip.
toggle Shows the tooltip when hidden, hides it when shown.