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.
<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:
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:
<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:
<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:
<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:
<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 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. |
String |
- |
data-tooltip-placement-value Preferred side of the trigger:
top (default), bottom, left or right. Flips and shifts to stay in view. |
String |
'top' |
data-tooltip-trigger-value 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. |
String |
'hover' |
data-tooltip-auto-hide-value Delay in milliseconds after which a shown tooltip hides itself; when 0, it stays until
hide. Defaults to 0. |
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. |