Dialog
A window overlaid on either the primary window or another dialog window, rendering the content underneath inert.
Loading...
<twig:Dialog id="edit_profile">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Open Dialog</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content>
<twig:Dialog:Header>
<twig:Dialog:Title>Edit profile</twig:Dialog:Title>
<twig:Dialog:Description>
Make changes to your profile here. Click save when you're done.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="grid gap-4">
<div class="grid gap-3">
<twig:Label for="name">Name</twig:Label>
<twig:Input id="name" name="name" value="Pedro Duarte" />
</div>
<div class="grid gap-3">
<twig:Label for="username">Username</twig:Label>
<twig:Input id="username" name="username" value="@peduarte" />
</div>
</div>
<twig:Dialog:Footer>
<twig:Dialog:Close>
<twig:Button variant="outline" {{ ...dialog_close_attrs }}>Cancel</twig:Button>
</twig:Dialog:Close>
<twig:Button type="submit">Save changes</twig:Button>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
Installation
bin/console ux:install dialog --kit shadcn
That's it!
Install the following Composer dependencies:
composer require symfony/ux-icons 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 Symfony app:
assets/controllers/dialog_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['trigger', 'dialog'];
static values = {
open: Boolean,
};
connect() {
if (this.openValue) {
this.open();
}
}
open() {
this.dialogTarget.showModal();
if (this.hasTriggerTarget) {
if (this.dialogTarget.getAnimations().length > 0) {
this.dialogTarget.addEventListener(
'transitionend',
() => {
this.triggerTarget.setAttribute('aria-expanded', 'true');
},
{ once: true }
);
} else {
this.triggerTarget.setAttribute('aria-expanded', 'true');
}
}
}
closeOnClickOutside({ target }) {
if (target === this.dialogTarget) {
this.close();
}
}
close() {
this.dialogTarget.close();
if (this.hasTriggerTarget) {
if (this.dialogTarget.getAnimations().length > 0) {
this.dialogTarget.addEventListener('transitionend', () => {
this.triggerTarget.setAttribute('aria-expanded', 'false');
});
} else {
this.triggerTarget.setAttribute('aria-expanded', 'false');
}
}
}
}
templates/components/Dialog.html.twig
{# @prop id string Unique identifier used to generate internal Dialog IDs #}
{# @prop open boolean Whether the dialog is open on initial render. Defaults to `false` #}
{# @block content The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content` #}
{%- props id, open = false -%}
{%- set _dialog_id = 'dialog-' ~ id -%}
{%- set _dialog_title_id = _dialog_id ~ '-title' -%}
{%- set _dialog_description_id = _dialog_id ~ '-description' -%}
{%- do provide('dialog.id', _dialog_id) -%}
{%- do provide('dialog.titleId', _dialog_title_id) -%}
{%- do provide('dialog.descriptionId', _dialog_description_id) -%}
<div {{ attributes.defaults({
'data-slot': 'dialog',
'data-controller': 'dialog',
'data-dialog-open-value': open,
'aria-labelledby': _dialog_title_id,
'aria-describedby': _dialog_description_id,
}) }}>
{% block content %}{% endblock %}
</div>
templates/components/Dialog/Close.html.twig
{# @block content The close trigger element (e.g., a `Button`) that closes the dialog when clicked #}
{%- set dialog_close_attrs = {
'data-slot': 'dialog-close',
'data-action': 'click->dialog#close'|html_attr_type('sst'),
} -%}
{%- block content %}{% endblock -%}
templates/components/Dialog/Content.html.twig
{# @prop showCloseButton boolean Whether to display the close button in the top-right corner. Defaults to `true` #}
{# @block content The dialog content, typically includes `Dialog:Header` and optionally `Dialog:Footer` #}
{%- props showCloseButton = true -%}
{%- set _dialog_id = inject('dialog.id') -%}
<dialog
id="{{ _dialog_id }}"
data-slot="dialog-content"
class="{{ ('fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 outline-none sm:max-w-sm opacity-0 scale-95 transition-all transition-discrete duration-100 backdrop:transition-discrete backdrop:duration-100 open:grid open:scale-100 open:opacity-100 open:backdrop:bg-black/10 supports-backdrop-filter:open:backdrop:backdrop-blur-xs starting:open:scale-95 starting:open:opacity-0 ' ~ attributes.render('class'))|tailwind_merge }}"
data-dialog-target="dialog"
data-action="keydown.esc->dialog#close:prevent click->dialog#closeOnClickOutside"
{{ attributes.without('class') }}
>
{%- block content %}{% endblock -%}
{% if showCloseButton %}
<twig:Button
type="button"
variant="ghost"
size="icon-sm"
class="absolute top-2 ltr:right-2 rtl:end-2"
data-slot="dialog-close"
data-action="click->dialog#close"
>
<twig:ux:icon name="lucide:x" />
<span class="sr-only">Close</span>
</twig:Button>
{% endif %}
</dialog>
templates/components/Dialog/Description.html.twig
{# @block content The descriptive text explaining the dialog purpose #}
{%- set _dialog_descriptionId = inject('dialog.descriptionId') -%}
<p
id="{{ _dialog_descriptionId }}"
data-slot="dialog-description"
class="{{ ('text-muted-foreground text-sm *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.without('id') }}
>
{%- block content %}{% endblock -%}
</p>
templates/components/Dialog/Footer.html.twig
{# @block content The footer area, typically contains action buttons #}
<footer
data-slot="dialog-footer"
class="{{ ('-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes }}
>
{%- block content %}{% endblock -%}
</footer>
templates/components/Dialog/Header.html.twig
{# @block content The header area, typically contains `Dialog:Title` and `Dialog:Description` #}
<header
data-slot="dialog-header"
class="{{ ('flex flex-col gap-2 ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes }}
>
{%- block content %}{% endblock -%}
</header>
templates/components/Dialog/Title.html.twig
{# @block content The title text of the dialog #}
{%- set _dialog_titleId = inject('dialog.titleId') -%}
<h2
id="{{ _dialog_titleId }}"
data-slot="dialog-title"
class="{{ ('cn-font-heading text-base leading-none font-medium ' ~ attributes.render('class'))|tailwind_merge }}"
{{ attributes.without('id') }}
>
{%- block content %}{% endblock -%}
</h2>
templates/components/Dialog/Trigger.html.twig
{# @block content The trigger element (e.g., a `Button`) that opens the dialog when clicked #}
{%- set dialog_trigger_attrs = {
'data-slot': 'dialog-trigger',
'data-action': 'click->dialog#open'|html_attr_type('sst'),
'data-dialog-target': 'trigger',
'aria-haspopup': 'dialog',
} -%}
{%- block content %}{% endblock -%}
templates/components/Button.html.twig
{# @prop variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant. Defaults to `default` #}
{# @prop size 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size. Defaults to `default` #}
{# @prop as 'button' The HTML tag to render. Defaults to `button` #}
{# @block content The button label and/or icon #}
{%- props variant = 'default', size = 'default', as = 'button' -%}
{%- set style = html_cva(
base: "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
variants: {
variant: {
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
outline: 'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
ghost: 'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
destructive: 'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-8 gap-1.5 px-2.5 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl:has-data-[icon=inline-start]:ps-2',
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl:has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg ltr:has-data-[icon=inline-end]:pr-1.5 rtl:has-data-[icon=inline-end]:pe-1.5 ltr:has-data-[icon=inline-start]:pl-1.5 rtl:has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: 'h-9 gap-1.5 px-2.5 ltr:has-data-[icon=inline-end]:pr-2 rtl:has-data-[icon=inline-end]:pe-2 ltr:has-data-[icon=inline-start]:pl-2 rtl:has-data-[icon=inline-start]:ps-2',
icon: 'size-8',
'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
},
},
) -%}
<{{ as }}
data-slot="button"
data-variant="{{ variant }}"
data-size="{{ size }}"
class="{{ style.apply({variant: variant, size: size}, attributes.render('class'))|tailwind_merge }}"
{{ attributes }}
>
{%- block content %}{% endblock -%}
</{{ as }}>
Happy coding!
Usage
<twig:Dialog id="delete_account">
<twig:Dialog:Trigger>
<twig:Button {{ ...dialog_trigger_attrs }}>Open</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content>
<twig:Dialog:Header>
<twig:Dialog:Title>Are you absolutely sure?</twig:Dialog:Title>
<twig:Dialog:Description>
This action cannot be undone. This will permanently delete your account
and remove your data from our servers.
</twig:Dialog:Description>
</twig:Dialog:Header>
</twig:Dialog:Content>
</twig:Dialog>
Examples
Custom Close Button
Replace the default close control with your own button.
Loading...
<twig:Dialog id="share_link">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Share</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content class="sm:max-w-md">
<twig:Dialog:Header>
<twig:Dialog:Title>Share link</twig:Dialog:Title>
<twig:Dialog:Description>
Anyone who has this link will be able to view this.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="flex items-center gap-2">
<div class="grid flex-1 gap-2">
<twig:Label for="link" class="sr-only">Link</twig:Label>
<twig:Input id="link" value="https://ui.shadcn.com/docs/installation" readonly />
</div>
</div>
<twig:Dialog:Footer class="sm:justify-start">
<twig:Dialog:Close>
<twig:Button type="button" {{ ...dialog_close_attrs }}>Close</twig:Button>
</twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
No Close Button
Set the showCloseButton prop to false to hide the close button.
Loading...
<twig:Dialog id="no_close_button">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>No Close Button</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content showCloseButton="{{ false }}">
<twig:Dialog:Header>
<twig:Dialog:Title>No Close Button</twig:Dialog:Title>
<twig:Dialog:Description>
This dialog doesn't have a close button in the top-right corner.
</twig:Dialog:Description>
</twig:Dialog:Header>
</twig:Dialog:Content>
</twig:Dialog>
Sticky Footer
Keep actions visible while the content scrolls.
Loading...
<twig:Dialog id="sticky_footer">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Sticky Footer</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content>
<twig:Dialog:Header>
<twig:Dialog:Title>Sticky Footer</twig:Dialog:Title>
<twig:Dialog:Description>
This dialog has a sticky footer that stays visible while the content scrolls.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="-mx-4 max-h-[50vh] overflow-y-auto px-4">
{% for i in 1..10 %}
<p class="mb-4 leading-normal">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
{% endfor %}
</div>
<twig:Dialog:Footer>
<twig:Dialog:Close>
<twig:Button variant="outline" {{ ...dialog_close_attrs }}>Close</twig:Button>
</twig:Dialog:Close>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
Scrollable Content
Long content can scroll while the header stays in view.
Loading...
<twig:Dialog id="scrollable_content">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>Scrollable Content</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content>
<twig:Dialog:Header>
<twig:Dialog:Title>Scrollable Content</twig:Dialog:Title>
<twig:Dialog:Description>
This is a dialog with scrollable content.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="-mx-4 max-h-[50vh] overflow-y-auto px-4">
{% for i in 1..10 %}
<p class="mb-4 leading-normal">Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
{% endfor %}
</div>
</twig:Dialog:Content>
</twig:Dialog>
RTL
To enable RTL support, set the dir="rtl" attribute on the root element.
Loading...
<div class="flex flex-col gap-4">
{# Arabic #}
<twig:Dialog id="dialog_rtl_ar">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>فتح الحوار</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content dir="rtl">
<twig:Dialog:Header>
<twig:Dialog:Title>تعديل الملف الشخصي</twig:Dialog:Title>
<twig:Dialog:Description>
قم بإجراء تغييرات على ملفك الشخصي هنا. انقر فوق حفظ عند الانتهاء.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="grid gap-4">
<div class="grid gap-3">
<twig:Label for="name-ar">الاسم</twig:Label>
<twig:Input id="name-ar" name="name" value="Pedro Duarte" />
</div>
<div class="grid gap-3">
<twig:Label for="username-ar">اسم المستخدم</twig:Label>
<twig:Input id="username-ar" name="username" value="@peduarte" />
</div>
</div>
<twig:Dialog:Footer>
<twig:Dialog:Close>
<twig:Button variant="outline" {{ ...dialog_close_attrs }}>إلغاء</twig:Button>
</twig:Dialog:Close>
<twig:Button type="submit">حفظ التغييرات</twig:Button>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
{# Hebrew #}
<twig:Dialog id="dialog_rtl_he">
<twig:Dialog:Trigger>
<twig:Button variant="outline" {{ ...dialog_trigger_attrs }}>הצג דיאלוג</twig:Button>
</twig:Dialog:Trigger>
<twig:Dialog:Content dir="rtl">
<twig:Dialog:Header>
<twig:Dialog:Title>ערוך נתונים</twig:Dialog:Title>
<twig:Dialog:Description>
ניתן לשנות נתונים כאן. לחץ שמור בסיום.
</twig:Dialog:Description>
</twig:Dialog:Header>
<div class="grid gap-4">
<div class="grid gap-3">
<twig:Label for="name-he">שם</twig:Label>
<twig:Input id="name-he" name="name" value="Pedro Duarte" />
</div>
<div class="grid gap-3">
<twig:Label for="username-he">שם משתמש</twig:Label>
<twig:Input id="username-he" name="username" value="@peduarte" />
</div>
</div>
<twig:Dialog:Footer>
<twig:Dialog:Close>
<twig:Button variant="outline" {{ ...dialog_close_attrs }}>בטל</twig:Button>
</twig:Dialog:Close>
<twig:Button type="submit">שמור שינויים</twig:Button>
</twig:Dialog:Footer>
</twig:Dialog:Content>
</twig:Dialog>
</div>
API Reference
Component Dialog
| Prop | Type | Description |
|---|---|---|
id |
string |
Unique identifier used to generate internal Dialog IDs |
open |
boolean |
Whether the dialog is open on initial render. Defaults to false |
| Block | Description |
|---|---|
content |
The dialog structure, typically includes Dialog:Trigger and Dialog:Content |
Component Dialog:Close
| Block | Description |
|---|---|
content |
The close trigger element (e.g., a Button) that closes the dialog when clicked |
Component Dialog:Content
| Prop | Type | Description |
|---|---|---|
showCloseButton |
boolean |
Whether to display the close button in the top-right corner. Defaults to true |
| Block | Description |
|---|---|
content |
The dialog content, typically includes Dialog:Header and optionally Dialog:Footer |
Component Dialog:Description
| Block | Description |
|---|---|
content |
The descriptive text explaining the dialog purpose |
Component Dialog:Footer
| Block | Description |
|---|---|
content |
The footer area, typically contains action buttons |
Component Dialog:Header
| Block | Description |
|---|---|
content |
The header area, typically contains Dialog:Title and Dialog:Description |
Component Dialog:Title
| Block | Description |
|---|---|
content |
The title text of the dialog |
Component Dialog:Trigger
| Block | Description |
|---|---|
content |
The trigger element (e.g., a Button) that opens the dialog when clicked |