View as Markdown

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 {{ ...tooltip_trigger_attrs }} variant="outline">Hover</twig:Button>
    </twig:Tooltip:Trigger>
    <twig:Tooltip:Content>
        <p>Add to library</p>
    </twig:Tooltip:Content>
</twig:Tooltip>

Installation

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

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 tales-from-a-dev/twig-tailwind-extra:^1.0.0 symfony/ux-twig-component:^3.1

Copy the following file(s) into your app:

import { Controller } from &#039;@hotwired/stimulus&#039;;

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 = [&#039;trigger&#039;, &#039;wrapper&#039;];

    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(&#039;data-side&#039;) || &#039;top&#039;;
        this.sideOffset = parseInt(this.wrapperElement.getAttribute(&#039;data-side-offset&#039;), 10) || 0;

        this.showTimeout = null;
        this.hideTimeout = null;
        this.dismiss = this.hide.bind(this);

        document.body.appendChild(this.wrapperElement);
        this.initialized = true;
    }

    disconnect() {
        this.#clearTimeouts();
        this.#removeDismissListeners();

        if (this.wrapperElement &amp;&amp; this.wrapperElement.parentNode === document.body) {
            this.element.appendChild(this.wrapperElement);
        }
    }

    wrapperTargetConnected() {
        // This case appear when live component rerender.
        // Because original wrapper is moved on body, the Smart rerender algorithm recreate a new wrapper.
        if (this.wrapperElement) {
            this.wrapperElement.remove();
            this.connect();
        }
    }

    show() {
        if (!this.initialized) {
            return;
        }

        this.#clearTimeouts();

        const delay = this.hasDelayDurationValue ? this.delayDurationValue : 0;

        this.showTimeout = setTimeout(() =&gt; {
            this.wrapperElement.setAttribute(&#039;open&#039;, &#039;&#039;);
            this.contentElement.setAttribute(&#039;open&#039;, &#039;&#039;);
            this.arrowElement.setAttribute(&#039;open&#039;, &#039;&#039;);
            this.#positionElements();
            // The tooltip is portaled to &lt;body&gt; and positioned absolutely, so it cannot follow
            // the trigger on scroll. Dismiss it instead (capture scrolls from any scroller).
            window.addEventListener(&#039;scroll&#039;, this.dismiss, true);
            window.addEventListener(&#039;resize&#039;, this.dismiss);
            this.showTimeout = null;
        }, delay);
    }

    hide() {
        if (!this.initialized) {
            return;
        }

        this.#clearTimeouts();
        this.#removeDismissListeners();
        this.wrapperElement.removeAttribute(&#039;open&#039;);
        this.contentElement.removeAttribute(&#039;open&#039;);
        this.arrowElement.removeAttribute(&#039;open&#039;);
    }

    #removeDismissListeners() {
        window.removeEventListener(&#039;scroll&#039;, this.dismiss, true);
        window.removeEventListener(&#039;resize&#039;, this.dismiss);
    }

    #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 &#039;left&#039;:
                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 &#039;top&#039;:
                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 &#039;right&#039;:
                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 &#039;bottom&#039;:
                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 in milliseconds before showing the tooltip. #}
{# @block content The tooltip structure, typically includes `Tooltip:Trigger` and `Tooltip:Content`. #}
{%- props id, delayDuration = 0 -%}
{%- set _tooltip_id = id -%}
{%- set _tooltip_trigger_id = id ~ &#039;_trigger&#039; -%}
{%- set _tooltip_wrapper_id = id ~ &#039;_wrapper&#039; -%}
{%- set _tooltip_content_id = id ~ &#039;_content&#039; -%}
{%- set _tooltip_arrow_id = id ~ &#039;_arrow&#039; -%}
{%- set _tooltip_delay_duration = delayDuration -%}
{%- do provide(&#039;tooltip.triggerId&#039;, _tooltip_trigger_id) -%}
{%- do provide(&#039;tooltip.wrapperId&#039;, _tooltip_wrapper_id) -%}
{%- do provide(&#039;tooltip.contentId&#039;, _tooltip_content_id) -%}
{%- do provide(&#039;tooltip.arrowId&#039;, _tooltip_arrow_id) -%}
&lt;div
    class=&quot;{{ (&#039;relative inline-block &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    {{ attributes.defaults({
        id: _tooltip_id,
        &#039;data-slot&#039;: &#039;tooltip&#039;,
        &#039;data-controller&#039;: &#039;tooltip&#039;,
        &#039;data-tooltip-delay-duration-value&#039;: _tooltip_delay_duration,
        &#039;data-tooltip-wrapper-selector-value&#039;: &#039;#&#039; ~ _tooltip_wrapper_id,
        &#039;data-tooltip-content-selector-value&#039;: &#039;#&#039; ~ _tooltip_content_id,
        &#039;data-tooltip-arrow-selector-value&#039;: &#039;#&#039; ~ _tooltip_arrow_id,
    }) }}
&gt;
    {%- block content %}{% endblock -%}
&lt;/div&gt;
{# @prop side &#039;top&#039;|&#039;right&#039;|&#039;bottom&#039;|&#039;left&#039; The preferred side to display the tooltip. #}
{# @prop sideOffset number The distance in pixels from the trigger. #}
{# @block content The tooltip text or content. #}
{%- props side = &#039;top&#039;, sideOffset = 0 -%}
{%- set _tooltip_wrapperId = inject(&#039;tooltip.wrapperId&#039;) -%}
{%- set _tooltip_contentId = inject(&#039;tooltip.contentId&#039;) -%}
{%- set _tooltip_arrowId = inject(&#039;tooltip.arrowId&#039;) -%}

&lt;div
    id=&quot;{{ _tooltip_wrapperId }}&quot;
    data-slot=&quot;tooltip-wrapper&quot;
    data-side=&quot;{{ side }}&quot;
    data-side-offset=&quot;{{ sideOffset }}&quot;
    data-tooltip-target=&quot;wrapper&quot;
    role=&quot;presentation&quot;
    class=&quot;isolate z-50 pointer-events-none open:pointer-events-auto&quot;
    style=&quot;position: fixed; left: 0; top: 0; will-change: transform;&quot;
&gt;
    &lt;div
        class=&quot;{{ (&#039;z-50 inline-flex w-fit max-w-xs items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background ltr:has-data-[slot=kbd]:pr-1.5 rtl:has-data-[slot=kbd]:pe-1.5 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm invisible opacity-0 scale-95 transition-all duration-200 open:visible open:opacity-100 open:scale-100 &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
        {{ attributes.defaults({
            id: _tooltip_contentId,
            role: &#039;tooltip&#039;,
            &#039;data-slot&#039;: &#039;tooltip-content&#039;,
            &#039;data-side&#039;: side,
            &#039;data-tooltip-target&#039;: &#039;content&#039;,
        }) }}
    &gt;
        {%- block content %}{% endblock -%}

        &lt;div
            id=&quot;{{ _tooltip_arrowId }}&quot;
            data-side=&quot;{{ side }}&quot;
            data-tooltip-target=&quot;arrow&quot;
            aria-hidden=&quot;true&quot;
            class=&quot;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&quot;
            style=&quot;position: absolute;&quot;
        &gt;&lt;/div&gt;{#- trim: this trailing whitespace text node would otherwise be a phantom flex item spaced by the content&#039;s gap-* class -#}
    &lt;/div&gt;
&lt;/div&gt;
{# @block content The element that triggers the tooltip on hover/focus. #}
{%- set _tooltip_triggerId = inject(&#039;tooltip.triggerId&#039;) -%}
{%- set _tooltip_contentId = inject(&#039;tooltip.contentId&#039;) -%}
{%- set tooltip_trigger_attrs = {
    id: _tooltip_triggerId,
    &#039;aria-describedby&#039;: _tooltip_contentId,
    &#039;data-slot&#039;: &#039;tooltip-trigger&#039;,
    &#039;data-tooltip-target&#039;: &#039;trigger&#039;,
    &#039;data-action&#039;: &#039;mouseenter-&gt;tooltip#show mouseleave-&gt;tooltip#hide focus-&gt;tooltip#show blur-&gt;tooltip#hide&#039;|html_attr_type(&#039;sst&#039;),
} -%}
{%- block content %}{% endblock -%}

Usage

<twig:Tooltip id="my-tooltip">
    <twig:Tooltip:Trigger>
        <twig:Button {{ ...tooltip_trigger_attrs }}>Hover</twig:Button>
    </twig:Tooltip:Trigger>
    <twig:Tooltip:Content>
        <p>Add to library</p>
    </twig:Tooltip:Content>
</twig:Tooltip>

Examples

Side

Use the side prop to change the position of the tooltip.

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 {{ ...tooltip_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 {{ ...tooltip_trigger_attrs }} variant="outline" size="icon-sm">
            <twig:ux:icon name="lucide:save" />
        </twig:Button>
    </twig:Tooltip:Trigger>
    <twig:Tooltip:Content>
        Save Changes <twig:Kbd>S</twig:Kbd>
    </twig:Tooltip:Content>
</twig:Tooltip>

Disabled Button

Show a tooltip on a disabled button by wrapping it with a span.

Loading...
<twig:Tooltip id="tooltip-disabled-button">
    <twig:Tooltip:Trigger>
        <span class="inline-block w-fit">
            <twig:Button {{ ...tooltip_trigger_attrs }} variant="outline" disabled class="pointer-events-auto">Disabled</twig:Button>
        </span>
    </twig:Tooltip:Trigger>
    <twig:Tooltip:Content>
        <p>This feature is currently unavailable</p>
    </twig:Tooltip:Content>
</twig:Tooltip>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

Loading...
<div class="flex flex-col gap-8">
    {# Arabic #}
    <div class="flex flex-wrap gap-2" dir="rtl">
        {% for side, label in {left: 'يسار', top: 'أعلى', bottom: 'أسفل', right: 'يمين'} %}
            <twig:Tooltip id="tooltip-rtl-ar-{{ side }}">
                <twig:Tooltip:Trigger>
                    <twig:Button {{ ...tooltip_trigger_attrs }} variant="outline" class="w-fit">
                        {{ label }}
                    </twig:Button>
                </twig:Tooltip:Trigger>
                <twig:Tooltip:Content side="{{ side }}">إضافة إلى المكتبة</twig:Tooltip:Content>
            </twig:Tooltip>
        {% endfor %}
    </div>

    {# Hebrew #}
    <div class="flex flex-wrap gap-2" dir="rtl">
        {% for side, label in {left: 'שמאל', top: 'למעלה', bottom: 'למטה', right: 'ימין'} %}
            <twig:Tooltip id="tooltip-rtl-he-{{ side }}">
                <twig:Tooltip:Trigger>
                    <twig:Button {{ ...tooltip_trigger_attrs }} variant="outline" class="w-fit">
                        {{ label }}
                    </twig:Button>
                </twig:Tooltip:Trigger>
                <twig:Tooltip:Content side="{{ side }}">הוסף לרשימה</twig:Tooltip:Content>
            </twig:Tooltip>
        {% endfor %}
    </div>
</div>

API Reference

<twig:Tooltip>

Prop Type Default
id 
string -
delayDuration 
number 0
Block Description
content The tooltip structure, typically includes Tooltip:Trigger and Tooltip:Content.

<twig:Tooltip:Content>

Prop Type Default
side 
'top'|'right'|'bottom'|'left' 'top'
sideOffset 
number 0
Block Description
content The tooltip text or content.

<twig:Tooltip:Trigger>

Block Description
content The element that triggers the tooltip on hover/focus.