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