View as Markdown

Popover

A click-triggered popup that displays rich content, anchored to its trigger.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 400px">
    <twig:Popover>
        <twig:Popover:Trigger>
            <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Open Popover</twig:Button>
        </twig:Popover:Trigger>
        <twig:Popover:Content align="start" class="w-80">
            <div class="grid gap-4">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">Dimensions</h4>
                    <p class="text-xs text-muted-foreground">Set the dimensions for the layer.</p>
                </div>
                <div class="grid gap-2">
                    <div class="grid grid-cols-3 items-center gap-4">
                        <twig:Label for="demo-width">Width</twig:Label>
                        <input id="demo-width" class="col-span-2 h-8 rounded-md border px-2 text-sm" value="100%">
                    </div>
                    <div class="grid grid-cols-3 items-center gap-4">
                        <twig:Label for="demo-max-width">Max. width</twig:Label>
                        <input id="demo-max-width" class="col-span-2 h-8 rounded-md border px-2 text-sm" value="300px">
                    </div>
                    <div class="grid grid-cols-3 items-center gap-4">
                        <twig:Label for="demo-height">Height</twig:Label>
                        <input id="demo-height" class="col-span-2 h-8 rounded-md border px-2 text-sm" value="25px">
                    </div>
                    <div class="grid grid-cols-3 items-center gap-4">
                        <twig:Label for="demo-max-height">Max. height</twig:Label>
                        <input id="demo-max-height" class="col-span-2 h-8 rounded-md border px-2 text-sm" value="none">
                    </div>
                </div>
            </div>
        </twig:Popover:Content>
    </twig:Popover>
</div>

Installation

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

Install the following Composer dependencies:

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

Copy the following file(s) into your app:

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

export default class extends Controller {
    static targets = ['trigger', 'content'];

    static values = {
        open: { type: Boolean, default: false },
        name: { type: String, default: '' },
    };

    #connected = false;

    connect() {
        this.updateState();
        this.#connected = true;
    }

    toggle(event) {
        event?.preventDefault();
        this.openValue = !this.openValue;
    }

    close() {
        this.openValue = false;
    }

    openValueChanged() {
        this.updateState();

        if (!this.openValue) {
            return;
        }

        if (this.nameValue) {
            window.dispatchEvent(
                new CustomEvent('popover:open', {
                    detail: { name: this.nameValue, source: this.element },
                })
            );
        }

        // Skip on initial render so an `open` popover does not steal focus on page load.
        if (this.#connected) {
            this.#focusContent();
        }
    }

    handleGroupOpen(event) {
        if (!this.nameValue || !this.openValue) {
            return;
        }
        if (event.detail.name !== this.nameValue || event.detail.source === this.element) {
            return;
        }
        this.openValue = false;
    }

    handleOutsideClick(event) {
        if (!this.openValue || this.element.contains(event.target)) {
            return;
        }
        this.openValue = false;
    }

    handleEscape(event) {
        if (!this.openValue || event.key !== 'Escape') {
            return;
        }
        this.openValue = false;
        this.triggerTargets[0]?.focus();
    }

    updateState() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        for (const trigger of this.triggerTargets) {
            trigger.setAttribute('aria-expanded', String(open));
            trigger.dataset.state = state;
        }

        for (const content of this.contentTargets) {
            content.dataset.state = state;
            content.setAttribute('aria-hidden', String(!open));
        }
    }

    #focusContent() {
        const content = this.contentTargets[0];
        const focusable =
            content?.querySelector('[autofocus]:not([disabled])') ??
            content?.querySelector(
                'input:not([type="hidden"]):not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
            );
        if (!focusable) {
            return;
        }

        // Wait two frames: `visibility` is transitioned, so the content computes as
        // `visibility: hidden` (not focusable) on the first frame after opening and is
        // only `visible` from the next one.
        requestAnimationFrame(() => requestAnimationFrame(() => focusable.focus()));
    }
}
templates/components/Popover.html.twig
{# @prop name string|null Group name shared by mutually exclusive popovers, so only one is open at a time. #}
{# @prop open boolean Whether the popover is open on initial render. #}
{# @block content A `Popover:Trigger` and a `Popover:Content`. #}
{%- props name = null, open = false -%}
{%- do provide('popover.open', open) -%}
<div
    data-slot="popover"
    data-state="{{ open ? 'open' : 'closed' }}"
    data-popover-open-value="{{ open ? 'true' : 'false' }}"
    data-popover-name-value="{{ name }}"
    {{ attributes.defaults({
        class: 'group/popover relative inline-block'|tailwind_classes,
        'data-controller': 'popover',
        'data-action': [
            'popover:open@window->popover#handleGroupOpen',
            'click@window->popover#handleOutsideClick',
            'keydown.esc@window->popover#handleEscape',
        ]|join(' ')|html_attr_type('sst'),
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Popover/Content.html.twig
{# @prop side 'top'|'right'|'bottom'|'left' Which side of the trigger the content appears on. #}
{# @prop align 'start'|'center'|'end' Alignment of the content along the cross axis. #}
{# @block content The content revealed when the popover is open. #}
{%- props side = 'bottom', align = 'center' -%}
{%- set _popover_open = inject('popover.open', false) -%}
{%- set style = html_cva(
    base: 'invisible scale-95 opacity-0 transition-[opacity,scale,visibility] duration-150 ease-out group-data-[state=open]/popover:visible group-data-[state=open]/popover:scale-100 group-data-[state=open]/popover:opacity-100 absolute z-50 w-72 rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md outline-none',
    variants: {
        side: {
            top: 'bottom-full mb-2',
            bottom: 'top-full mt-2',
            left: 'right-full top-1/2 -translate-y-1/2 mr-2 origin-right',
            right: 'left-full top-1/2 -translate-y-1/2 ml-2 origin-left',
        },
    },
    compound_variants: [
        {side: ['top'], align: ['start'], class: 'start-0 ltr:origin-bottom-left rtl:origin-bottom-right'},
        {side: ['top'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-bottom'},
        {side: ['top'], align: ['end'], class: 'end-0 ltr:origin-bottom-right rtl:origin-bottom-left'},
        {side: ['bottom'], align: ['start'], class: 'start-0 ltr:origin-top-left rtl:origin-top-right'},
        {side: ['bottom'], align: ['center'], class: 'start-1/2 -translate-x-1/2 rtl:translate-x-1/2 origin-top'},
        {side: ['bottom'], align: ['end'], class: 'end-0 ltr:origin-top-right rtl:origin-top-left'},
    ],
) -%}
<div
    role="dialog"
    data-slot="popover-content"
    data-popover-target="content"
    data-side="{{ side }}"
    data-align="{{ align }}"
    data-state="{{ _popover_open ? 'open' : 'closed' }}"
    aria-hidden="{{ _popover_open ? 'false' : 'true' }}"
    {{ attributes.defaults({
        class: style.apply({side: side, align: align})|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
templates/components/Popover/Trigger.html.twig
{# @block content The clickable trigger (e.g., a `Button`) that opens the popover. #}
{%- set _popover_open = inject('popover.open', false) -%}
{%- set popover_trigger_attrs = {
    'data-slot': 'popover-trigger',
    'data-popover-target': 'trigger',
    'data-action': 'click->popover#toggle'|html_attr_type('sst'),
    'aria-haspopup': 'dialog',
    'aria-expanded': _popover_open ? 'true' : 'false',
    'data-state': _popover_open ? 'open' : 'closed',
} -%}
{%- block content %}{% endblock -%}

Usage

<twig:Popover name="dimensions" open="false">
    <twig:Popover:Trigger>
        <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Open popover</twig:Button>
    </twig:Popover:Trigger>
    <twig:Popover:Content side="bottom" align="center">
        Popover content goes here.
    </twig:Popover:Content>
</twig:Popover>

Examples

Basic

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 260px">
    <twig:Popover>
        <twig:Popover:Trigger>
            <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Open Popover</twig:Button>
        </twig:Popover:Trigger>
        <twig:Popover:Content align="start">
            <div class="space-y-1">
                <h4 class="text-sm font-medium leading-none">Dimensions</h4>
                <p class="text-xs text-muted-foreground">Set the dimensions for the layer.</p>
            </div>
        </twig:Popover:Content>
    </twig:Popover>
</div>

Alignments

Use the align prop to align the content to the start, center or end of the trigger.

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 260px">
    <div class="flex gap-6">
        <twig:Popover name="alignments-demo">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" size="sm" {{ ...popover_trigger_attrs }}>Start</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content align="start" class="w-40">
                Aligned to start
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="alignments-demo">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" size="sm" {{ ...popover_trigger_attrs }}>Center</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content align="center" class="w-40">
                Aligned to center
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="alignments-demo">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" size="sm" {{ ...popover_trigger_attrs }}>End</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content align="end" class="w-40">
                Aligned to end
            </twig:Popover:Content>
        </twig:Popover>
    </div>
</div>

With Form

100%
Loading...
<div class="flex items-start justify-center pt-6" style="min-height: 340px">
    <twig:Popover>
        <twig:Popover:Trigger>
            <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>Open Popover</twig:Button>
        </twig:Popover:Trigger>
        <twig:Popover:Content align="start" class="w-64">
            <div class="grid gap-4">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">Dimensions</h4>
                    <p class="text-xs text-muted-foreground">Set the dimensions for the layer.</p>
                </div>
                <div class="grid gap-3">
                    <div class="flex items-center gap-3">
                        <twig:Label for="form-width" class="w-1/2">Width</twig:Label>
                        <input id="form-width" class="h-8 w-full rounded-md border px-2 text-sm" value="100%">
                    </div>
                    <div class="flex items-center gap-3">
                        <twig:Label for="form-height" class="w-1/2">Height</twig:Label>
                        <input id="form-height" class="h-8 w-full rounded-md border px-2 text-sm" value="25px">
                    </div>
                </div>
            </div>
        </twig:Popover:Content>
    </twig:Popover>
</div>

RTL

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

100%
Loading...
<div class="flex flex-col items-center gap-12 py-12" style="min-height: 520px">
    {# Arabic #}
    <div dir="rtl" class="flex flex-wrap justify-center gap-2">
        <twig:Popover name="rtl-ar">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>يسار</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="left">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">الأبعاد</h4>
                    <p class="text-xs text-muted-foreground">تعيين الأبعاد للطبقة.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-ar">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>أعلى</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="top">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">الأبعاد</h4>
                    <p class="text-xs text-muted-foreground">تعيين الأبعاد للطبقة.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-ar">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>أسفل</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="bottom">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">الأبعاد</h4>
                    <p class="text-xs text-muted-foreground">تعيين الأبعاد للطبقة.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-ar">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>يمين</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="right">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">الأبعاد</h4>
                    <p class="text-xs text-muted-foreground">تعيين الأبعاد للطبقة.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
    </div>

    {# Hebrew #}
    <div dir="rtl" class="flex flex-wrap justify-center gap-2">
        <twig:Popover name="rtl-he">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>שמאל</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="left">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">מימדים</h4>
                    <p class="text-xs text-muted-foreground">הגדר את המימדים לשכבה.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-he">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>למעלה</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="top">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">מימדים</h4>
                    <p class="text-xs text-muted-foreground">הגדר את המימדים לשכבה.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-he">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>למטה</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="bottom">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">מימדים</h4>
                    <p class="text-xs text-muted-foreground">הגדר את המימדים לשכבה.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
        <twig:Popover name="rtl-he">
            <twig:Popover:Trigger>
                <twig:Button variant="outline" {{ ...popover_trigger_attrs }}>ימין</twig:Button>
            </twig:Popover:Trigger>
            <twig:Popover:Content side="right">
                <div class="space-y-1">
                    <h4 class="text-sm font-medium leading-none">מימדים</h4>
                    <p class="text-xs text-muted-foreground">הגדר את המימדים לשכבה.</p>
                </div>
            </twig:Popover:Content>
        </twig:Popover>
    </div>
</div>

API Reference

<twig:Popover>

Prop Type Default
name 
string|null null
open 
boolean false
Block Description
content A Popover:Trigger and a Popover:Content.

<twig:Popover:Content>

Prop Type Default
side 
'top'|'right'|'bottom'|'left' 'bottom'
align 
'start'|'center'|'end' 'center'
Block Description
content The content revealed when the popover is open.

<twig:Popover:Trigger>

Block Description
content The clickable trigger (e.g., a Button) that opens the popover.