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&apos;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&apos;t have a close button in the top-right corner.
            </twig:Dialog:Description>
        </twig:Dialog:Header>
    </twig:Dialog:Content>
</twig:Dialog>

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