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 Group name shared by mutually exclusive popovers, so only one is open at a time.
|
string|null |
null |
open Whether the popover is open on initial render.
|
boolean |
false |
| Block | Description |
|---|---|
content |
A Popover:Trigger and a Popover:Content. |
<twig:Popover:Content>
| Prop | Type | Default |
|---|---|---|
side Which side of the trigger the content appears on.
|
'top'|'right'|'bottom'|'left' |
'bottom' |
align Alignment of the content along the cross axis.
|
'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. |