Tooltip
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it.
Loading...
<twig:Tooltip id="tooltip-demo">
<twig:Tooltip:Trigger>
<twig:Button {{ ...trigger_attrs }} variant="outline">Hover</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content>
<p>Add to library</p>
</twig:Tooltip:Content>
</twig:Tooltip>
Installation
bin/console ux:install tooltip --kit shadcn
That's it!
Install the following Composer dependencies:
composer require twig/extra-bundle twig/html-extra:^3.12.0 tales-from-a-dev/twig-tailwind-extra:^1.0.0
Copy the following file(s) into your Symfony app:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static values = {
delayDuration: Number,
// Using targets does not work if the elements are moved in the DOM (document.body.appendChild)
// and using outlets does not work either if elements are children of the controller element.
wrapperSelector: String,
contentSelector: String,
arrowSelector: String,
};
static targets = ['trigger'];
connect() {
this.initialized = false;
this.wrapperElement = document.querySelector(this.wrapperSelectorValue);
this.contentElement = document.querySelector(this.contentSelectorValue);
this.arrowElement = document.querySelector(this.arrowSelectorValue);
if (!this.wrapperElement || !this.contentElement || !this.arrowElement) {
return;
}
this.side = this.wrapperElement.getAttribute('data-side') || 'top';
this.sideOffset = parseInt(this.wrapperElement.getAttribute('data-side-offset'), 10) || 0;
this.showTimeout = null;
this.hideTimeout = null;
document.body.appendChild(this.wrapperElement);
this.initialized = true;
}
disconnect() {
this.#clearTimeouts();
if (this.wrapperElement && this.wrapperElement.parentNode === document.body) {
this.element.appendChild(this.wrapperElement);
}
}
show() {
if (!this.initialized) {
return;
}
this.#clearTimeouts();
const delay = this.hasDelayDurationValue ? this.delayDurationValue : 0;
this.showTimeout = setTimeout(() => {
this.wrapperElement.setAttribute('open', '');
this.contentElement.setAttribute('open', '');
this.arrowElement.setAttribute('open', '');
this.#positionElements();
this.showTimeout = null;
}, delay);
}
hide() {
if (!this.initialized) {
return;
}
this.#clearTimeouts();
this.wrapperElement.removeAttribute('open');
this.contentElement.removeAttribute('open');
this.arrowElement.removeAttribute('open');
}
#clearTimeouts() {
if (this.showTimeout) {
clearTimeout(this.showTimeout);
this.showTimeout = null;
}
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = null;
}
}
#positionElements() {
const triggerRect = this.triggerTarget.getBoundingClientRect();
const contentRect = this.contentElement.getBoundingClientRect();
const arrowRect = this.arrowElement.getBoundingClientRect();
let wrapperLeft = 0;
let wrapperTop = 0;
let arrowLeft = null;
let arrowTop = null;
switch (this.side) {
case 'left':
wrapperLeft = triggerRect.left - contentRect.width - arrowRect.width / 2 - this.sideOffset;
wrapperTop = triggerRect.top - contentRect.height / 2 + triggerRect.height / 2;
arrowTop = contentRect.height / 2 - arrowRect.height / 2;
break;
case 'top':
wrapperLeft = triggerRect.left - contentRect.width / 2 + triggerRect.width / 2;
wrapperTop = triggerRect.top - contentRect.height - arrowRect.height / 2 - this.sideOffset;
arrowLeft = contentRect.width / 2 - arrowRect.width / 2;
break;
case 'right':
wrapperLeft = triggerRect.right + arrowRect.width / 2 + this.sideOffset;
wrapperTop = triggerRect.top - contentRect.height / 2 + triggerRect.height / 2;
arrowTop = contentRect.height / 2 - arrowRect.height / 2;
break;
case 'bottom':
wrapperLeft = triggerRect.left - contentRect.width / 2 + triggerRect.width / 2;
wrapperTop = triggerRect.bottom + arrowRect.height / 2 + this.sideOffset;
arrowLeft = contentRect.width / 2 - arrowRect.width / 2;
break;
}
this.wrapperElement.style.transform = `translate3d(${wrapperLeft}px, ${wrapperTop}px, 0)`;
if (arrowLeft !== null) {
this.arrowElement.style.left = `${arrowLeft}px`;
}
if (arrowTop !== null) {
this.arrowElement.style.top = `${arrowTop}px`;
}
}
}
{# @prop id string Unique identifier for the Tooltip #}
{# @prop delayDuration number Delay duration in milliseconds before showing the tooltip, default to `0` #}
{# @block content The default block #}
{%- props id, delayDuration = 0 -%}
{%- set _tooltip_id = id -%}
{%- set _tooltip_trigger_id = id ~ '_trigger' -%}
{%- set _tooltip_wrapper_id = id ~ '_wrapper' -%}
{%- set _tooltip_content_id = id ~ '_content' -%}
{%- set _tooltip_arrow_id = id ~ '_arrow' -%}
{%- set _tooltip_delay_duration = delayDuration -%}
<div
class="{{ 'relative inline-block ' ~ attributes.render('class')|tailwind_merge }}"
{{ attributes.defaults({
id: _tooltip_id,
'data-slot': 'tooltip',
'data-controller': 'tooltip',
'data-tooltip-delay-duration-value': _tooltip_delay_duration,
'data-tooltip-wrapper-selector-value': '#' ~ _tooltip_wrapper_id,
'data-tooltip-content-selector-value': '#' ~ _tooltip_content_id,
'data-tooltip-arrow-selector-value': '#' ~ _tooltip_arrow_id,
}) }}
>
{%- block content %}{% endblock -%}
</div>
{# @prop side 'top'|'right'|'bottom'|'left' The preferred side of the trigger to render against when open, default to `top` #}
{# @prop sideOffset number The distance in pixels from the trigger, default to `0` #}
{# @block content The default block #}
{%- props side = 'top', sideOffset = 0 -%}
<div
id="{{ _tooltip_wrapper_id }}"
data-slot="tooltip-wrapper"
data-side="{{ side }}"
data-side-offset="{{ sideOffset }}"
role="presentation"
class="isolate z-50"
style="position: absolute; left: 0px; top: 0px; will-change: transform;"
>
<div
class="{{ ('invisible open:visible transition-all duration-200 open:opacity-100 open:scale-100 opacity-0 scale-95 bg-foreground text-background z-50 w-fit max-w-xs rounded-md px-3 py-1.5 text-xs ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.defaults({
id: _tooltip_content_id,
role: 'tooltip',
'data-slot': 'tooltip-content',
'data-side': side,
'data-tooltip-target': 'content',
}) }}
>
{%- block content %}{% endblock -%}
<div
id="{{ _tooltip_arrow_id }}"
data-side="{{ side }}"
data-tooltip-target="arrow"
aria-hidden="true"
class="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] data-[side=bottom]:top-1 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5"
style="position: absolute;"
></div>
</div>
</div>
{# @block content The default block #}
{%- set trigger_attrs = {
id: _tooltip_trigger_id,
'aria-describedby': _tooltip_content_id,
'data-slot': 'tooltip-trigger',
'data-tooltip-target': 'trigger',
'data-action': 'mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide',
} -%}
{%- block content %}{% endblock -%}
Happy coding!
Usage
<twig:Tooltip id="my-tooltip">
<twig:Tooltip:Trigger>
<twig:Button {{ ...trigger_attrs }}>Hover</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content>
<p>Add to library</p>
</twig:Tooltip:Content>
</twig:Tooltip>
Examples
Side
Loading...
<div class="flex flex-wrap gap-2">
{% for side in ['left', 'top', 'bottom', 'right'] %}
<twig:Tooltip id="tooltip-side-{{ side }}">
<twig:Tooltip:Trigger>
<twig:Button {{ ...trigger_attrs }} variant="outline" class="w-fit">
{{ side|capitalize }}
</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content side="{{ side }}">
<p>Add to library</p>
</twig:Tooltip:Content>
</twig:Tooltip>
{% endfor %}
</div>
With Keyboard Shortcut
Loading...
<twig:Tooltip id="tooltip-with-keyboard-shortcut">
<twig:Tooltip:Trigger>
<twig:Button {{ ...trigger_attrs }} variant="outline" size="icon-sm">
<twig:ux:icon name="lucide:save" />
</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content class="pr-1.5">
<div class="flex items-center gap-2">
Save Changes <twig:Kbd>S</twig:Kbd>
</div>
</twig:Tooltip:Content>
</twig:Tooltip>
Disabled Button
Loading...
<twig:Tooltip id="tooltip-disabled-button">
<twig:Tooltip:Trigger>
<twig:Button {{ ...trigger_attrs }} variant="outline" disabled>Disabled</twig:Button>
</twig:Tooltip:Trigger>
<twig:Tooltip:Content>
<p>This feature is currently unavailable</p>
</twig:Tooltip:Content>
</twig:Tooltip>
API Reference
Tooltip
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique identifier for the Tooltip |
delayDuration |
number |
Delay duration in milliseconds before showing the tooltip, default to 0 |
| Block | Description |
|---|---|
content |
The default block |
Tooltip:Content
| Prop | Type | Description |
|---|---|---|
side |
'top'|'right'|'bottom'|'left' |
The preferred side of the trigger to render against when open, default to top |
sideOffset |
number |
The distance in pixels from the trigger, default to 0 |
| Block | Description |
|---|---|
content |
The default block |
Tooltip:Trigger
| Block | Description |
|---|---|
content |
The default block |