Hover Card

For sighted users to preview content available behind a link.

Loading...
<twig:HoverCard openDelay="10" closeDelay="100">
    <twig:HoverCard:Trigger>
        <twig:Button variant="link" {{ ...hover_card_trigger_attrs }}>Hover Here</twig:Button>
    </twig:HoverCard:Trigger>
    <twig:HoverCard:Content class="flex w-64 flex-col gap-0.5">
        <div class="font-semibold">@symfony</div>
        <div>The PHP framework for web applications — created by @fabpot.</div>
        <div class="mt-1 text-xs text-muted-foreground">Joined October 2010</div>
    </twig:HoverCard:Content>
</twig:HoverCard>

Installation

Note

Available since UX Toolkit 3.0.

bin/console ux:install hover-card --kit shadcn

That's it!

Install the following Composer dependencies:

composer require tales-from-a-dev/twig-tailwind-extra:^1.0.0

Copy the following file(s) into your Symfony app:

assets/controllers/hover_card_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static values = {
        openDelay: { type: Number, default: 0 },
        closeDelay: { type: Number, default: 0 },
    };

    connect() {
        this.openTimeout = null;
        this.closeTimeout = null;
        this.element.dataset.state = 'closed';
    }

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

    show() {
        this.#clearTimeouts();
        this.openTimeout = setTimeout(() => {
            this.element.dataset.state = 'open';
            this.openTimeout = null;
        }, this.openDelayValue);
    }

    hide() {
        this.#clearTimeouts();
        this.closeTimeout = setTimeout(() => {
            this.element.dataset.state = 'closed';
            this.closeTimeout = null;
        }, this.closeDelayValue);
    }

    #clearTimeouts() {
        if (this.openTimeout) {
            clearTimeout(this.openTimeout);
            this.openTimeout = null;
        }
        if (this.closeTimeout) {
            clearTimeout(this.closeTimeout);
            this.closeTimeout = null;
        }
    }
}
templates/components/HoverCard.html.twig
{# @prop openDelay number Delay in milliseconds before showing the content. Defaults to `0` #}
{# @prop closeDelay number Delay in milliseconds before hiding the content. Defaults to `0` #}
{# @block content The hover card structure, typically a `HoverCard:Trigger` and `HoverCard:Content` #}
{%- props openDelay = 0, closeDelay = 0 -%}
<span
    class="{{ ('relative inline-block ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes.defaults({
        'data-slot': 'hover-card',
        'data-controller': 'hover-card',
        'data-hover-card-open-delay-value': openDelay,
        'data-hover-card-close-delay-value': closeDelay,
        'data-action': 'mouseenter->hover-card#show mouseleave->hover-card#hide',
    }) }}
>
    {%- block content %}{% endblock -%}
</span>
templates/components/HoverCard/Content.html.twig
{# @prop side 'bottom'|'top'|'left'|'right' The side where the content appears. Defaults to `bottom` #}
{# @block content The content revealed on hover #}
{%- props side = 'bottom' -%}
{%- set style = html_cva(
    base: 'invisible opacity-0 in-data-[state=open]:visible in-data-[state=open]:opacity-100 transition-opacity duration-100 absolute z-50 w-64 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden ',
    variants: {
        side: {
            bottom: 'left-1/2 top-full mt-2 -translate-x-1/2',
            top: 'left-1/2 top-auto bottom-full mb-2 mt-0 -translate-x-1/2',
            left: 'top-1/2 left-auto right-full -translate-y-1/2 translate-x-0 mt-0 mr-2',
            right: 'top-1/2 left-full -translate-y-1/2 translate-x-0 mt-0 ml-2',
        },
    },
    default_variant: {
        side: 'bottom',
    },
) -%}
<span
    role="tooltip"
    data-side="{{ side }}"
    class="{{ style.apply({side: side}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes.defaults({
        'data-slot': 'hover-card-content',
    }) }}
>
    {%- block content %}{% endblock -%}
</span>
templates/components/HoverCard/Trigger.html.twig
{# @block content The element that reveals the hover card on hover or focus #}
{%- set hover_card_trigger_attrs = {
    'data-slot': 'hover-card-trigger',
    tabindex: 0,
    'data-action': 'focus->hover-card#show blur->hover-card#hide'|html_attr_type('sst'),
} -%}
{%- block content %}{% endblock -%}

Happy coding!

Usage

<twig:HoverCard>
    <twig:HoverCard:Trigger>
        <a href="#" class="underline" {{ ...hover_card_trigger_attrs }}>@symfony</a>
    </twig:HoverCard:Trigger>
    <twig:HoverCard:Content>
        The Symfony PHP framework — official organization on GitHub.
    </twig:HoverCard:Content>
</twig:HoverCard>

Examples

Basic

Loading...
<twig:HoverCard openDelay="10" closeDelay="100">
    <twig:HoverCard:Trigger>
        <twig:Button variant="link" {{ ...hover_card_trigger_attrs }}>Hover Here</twig:Button>
    </twig:HoverCard:Trigger>
    <twig:HoverCard:Content class="flex w-64 flex-col gap-0.5">
        <div class="font-semibold">@symfony</div>
        <div>The PHP framework for web applications — created by @fabpot.</div>
        <div class="mt-1 text-xs text-muted-foreground">Joined October 2010</div>
    </twig:HoverCard:Content>
</twig:HoverCard>

Sides

Loading...
<div class="flex flex-wrap justify-center gap-2">
    {% for side in ['left', 'top', 'bottom', 'right'] %}
        <twig:HoverCard openDelay="100" closeDelay="100">
            <twig:HoverCard:Trigger>
                <twig:Button variant="outline" class="capitalize" {{ ...hover_card_trigger_attrs }}>{{ side }}</twig:Button>
            </twig:HoverCard:Trigger>
            <twig:HoverCard:Content side="{{ side }}">
                <div class="flex flex-col gap-1">
                    <h4 class="font-medium">Hover Card</h4>
                    <p>This hover card appears on the {{ side }} side of the trigger.</p>
                </div>
            </twig:HoverCard:Content>
        </twig:HoverCard>
    {% endfor %}
</div>

RTL

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

Loading...
<div class="flex flex-col items-center gap-24 py-12">
    {# Arabic #}
    <div class="flex flex-wrap justify-center gap-2" dir="rtl">
        {% for side, label in {left: 'يسار', top: 'أعلى', bottom: 'أسفل', right: 'يمين'} %}
            <twig:HoverCard openDelay="10" closeDelay="100">
                <twig:HoverCard:Trigger>
                    <twig:Button variant="outline" {{ ...hover_card_trigger_attrs }}>{{ label }}</twig:Button>
                </twig:HoverCard:Trigger>
                <twig:HoverCard:Content side="{{ side }}" class="flex w-64 flex-col gap-1">
                    <div class="font-semibold">سماعات لاسلكية</div>
                    <div class="text-sm text-muted-foreground">٩٩.٩٩ $</div>
                </twig:HoverCard:Content>
            </twig:HoverCard>
        {% endfor %}
    </div>

    {# Hebrew #}
    <div class="flex flex-wrap justify-center gap-2" dir="rtl">
        {% for side, label in {left: 'שמאל', top: 'למעלה', bottom: 'למטה', right: 'ימין'} %}
            <twig:HoverCard openDelay="10" closeDelay="100">
                <twig:HoverCard:Trigger>
                    <twig:Button variant="outline" {{ ...hover_card_trigger_attrs }}>{{ label }}</twig:Button>
                </twig:HoverCard:Trigger>
                <twig:HoverCard:Content side="{{ side }}" class="flex w-64 flex-col gap-1">
                    <div class="font-semibold">אוזניות אלחוטיות</div>
                    <div class="text-sm text-muted-foreground">99.99 $</div>
                </twig:HoverCard:Content>
            </twig:HoverCard>
        {% endfor %}
    </div>
</div>

API Reference

Component HoverCard

Prop Type Description
openDelay number Delay in milliseconds before showing the content. Defaults to 0
closeDelay number Delay in milliseconds before hiding the content. Defaults to 0
Block Description
content The hover card structure, typically a HoverCard:Trigger and HoverCard:Content

Component HoverCard:Content

Prop Type Description
side 'bottom'|'top'|'left'|'right' The side where the content appears. Defaults to bottom
Block Description
content The content revealed on hover

Component HoverCard:Trigger

Block Description
content The element that reveals the hover card on hover or focus