Alert Dialog

A modal dialog that interrupts the user with important content and expects a response.

Loading...
<twig:AlertDialog id="delete_account">
    <twig:AlertDialog:Trigger>
        <twig:Button variant="outline" {{ ...alert_dialog_trigger_attrs }}>Show Dialog</twig:Button>
    </twig:AlertDialog:Trigger>
    <twig:AlertDialog:Content>
        <twig:AlertDialog:Header>
            <twig:AlertDialog:Title>Are you absolutely sure?</twig:AlertDialog:Title>
            <twig:AlertDialog:Description>
                This action cannot be undone. This will permanently delete your
                account and remove your data from our servers.
            </twig:AlertDialog:Description>
        </twig:AlertDialog:Header>
        <twig:AlertDialog:Footer>
            <twig:AlertDialog:Cancel>Cancel</twig:AlertDialog:Cancel>
            <twig:AlertDialog:Action>Continue</twig:AlertDialog:Action>
        </twig:AlertDialog:Footer>
    </twig:AlertDialog:Content>
</twig:AlertDialog>

Installation

bin/console ux:install alert-dialog --kit shadcn

That's it!

Install the following Composer dependencies:

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

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

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');
                });
            } else {
                this.triggerTarget.setAttribute('aria-expanded', 'true');
            }
        }
    }

    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');
            }
        }
    }
}
{# @prop id string Unique identifier used to generate internal AlertDialog IDs #}
{# @prop open boolean Whether the dialog is open on initial render. Defaults to `false` #}
{# @block content The dialog structure, typically includes `AlertDialog:Trigger` and `AlertDialog:Content` #}
{%- props id, open = false -%}

{%- set _alert_dialog_id = 'alert-dialog-' ~ id -%}
{%- set _alert_dialog_title_id = _alert_dialog_id ~ '-title' -%}
{%- set _alert_dialog_description_id = _alert_dialog_id ~ '-description' -%}
<div {{ attributes.defaults({
    'data-controller': 'alert-dialog',
    'data-alert-dialog-open-value': open,
    'aria-labelledby': _alert_dialog_title_id,
    'aria-describedby': _alert_dialog_description_id,
}) }}>
    {% block content %}{% endblock %}
</div>
{# @prop variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant. Defaults to `default` #}
{# @block content The action button label #}
{%- props variant = 'default' -%}
<twig:Button variant="{{ variant }}" {{ ...attributes }}>
    {{- block(outerBlocks.content) -}}
</twig:Button>
{# @block content The cancel button label #}
<twig:Button variant="outline" data-action="click->alert-dialog#close" {{ ...attributes }}>
    {{- block(outerBlocks.content) -}}
</twig:Button>
{# @block content The dialog content, typically includes `AlertDialog:Header` and `AlertDialog:Footer` #}
<dialog
    id="{{ _alert_dialog_id }}"
    class="{{ ('text-foreground bg-background fixed top-[50%] left-[50%] z-50 max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] scale-95 gap-4 rounded-lg border p-6 opacity-0 shadow-lg transition-all transition-discrete duration-200 backdrop:transition-discrete backdrop:duration-150 open:grid open:scale-100 open:opacity-100 open:backdrop:bg-black/50 sm:max-w-lg starting:open:scale-95 starting:open:opacity-0 ' ~ attributes.render('class'))|tailwind_merge }}"
    data-alert-dialog-target="dialog"
    data-action="keydown.esc->alert-dialog#close:prevent"
    {{ attributes.without('id') }}
>
    {%- block content %}{% endblock -%}
</dialog>
{# @block content The descriptive text explaining the alert dialog purpose #}
<p
    id="{{ _alert_dialog_description_id }}"
    class="{{ ('text-muted-foreground text-sm ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes.without('id') }}
>
    {%- block content %}{% endblock -%}
</p>
{# @block content The footer area, typically contains `AlertDialog:Cancel` and `AlertDialog:Action` buttons #}
<footer
    class="{{ ('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</footer>
{# @block content The header area, typically contains `AlertDialog:Title` and `AlertDialog:Description` #}
<header
    class="{{ ('flex flex-col gap-2 text-center sm:text-left ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</header>
{# @block content The title text of the alert dialog #}
<h2
    id="{{ _alert_dialog_title_id }}"
    class="{{ ('text-lg font-semibold ' ~ attributes.render('class'))|tailwind_merge }}"
    {{ attributes.without('id') }}
>
    {%- block content %}{% endblock -%}
</h2>
{# @block content The trigger element (e.g., a `Button`) that opens the dialog when clicked #}
{%- set alert_dialog_trigger_attrs = {
    'data-action': 'click->alert-dialog#open'|html_attr_type('sst'),
    'data-alert-dialog-target': 'trigger',
    'aria-haspopup': 'dialog',
    'aria-expanded': 'false',
} -%}
{%- block content %}{% endblock -%}
{# @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: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-[3px] aria-invalid:ring-[3px] [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
    variants: {
        variant: {
            default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
            secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
            destructive: 'bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30',
            outline: 'border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground',
            ghost: 'hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground',
            link: 'text-primary underline-offset-4 hover:underline',
        },
        size: {
            default: 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
            xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-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 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3',
            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"
    class="{{ style.apply({variant: variant, size: size}, attributes.render('class'))|tailwind_merge }}"
    {{ attributes }}
>
    {%- block content %}{% endblock -%}
</{{ as }}>

Happy coding!

Usage

<twig:AlertDialog id="delete_account">
    <twig:AlertDialog:Trigger>
        <twig:Button variant="outline" {{ ...alert_dialog_trigger_attrs }}>Show Dialog</twig:Button>
    </twig:AlertDialog:Trigger>
    <twig:AlertDialog:Content>
        <twig:AlertDialog:Header>
            <twig:AlertDialog:Title>Are you absolutely sure?</twig:AlertDialog:Title>
            <twig:AlertDialog:Description>
                This action cannot be undone. This will permanently delete your
                account and remove your data from our servers.
            </twig:AlertDialog:Description>
        </twig:AlertDialog:Header>
        <twig:AlertDialog:Footer>
            <twig:AlertDialog:Cancel>Cancel</twig:AlertDialog:Cancel>
            <twig:AlertDialog:Action>Continue</twig:AlertDialog:Action>
        </twig:AlertDialog:Footer>
    </twig:AlertDialog:Content>
</twig:AlertDialog>

Examples

Opened by default

Loading...
<twig:AlertDialog id="delete_account" open>
    <twig:AlertDialog:Trigger>
        <twig:Button variant="outline" {{ ...alert_dialog_trigger_attrs }}>Show Dialog</twig:Button>
    </twig:AlertDialog:Trigger>
    <twig:AlertDialog:Content>
        <twig:AlertDialog:Header>
            <twig:AlertDialog:Title>Are you absolutely sure?</twig:AlertDialog:Title>
            <twig:AlertDialog:Description>
                This action cannot be undone. This will permanently delete your
                account and remove your data from our servers.
            </twig:AlertDialog:Description>
        </twig:AlertDialog:Header>
        <twig:AlertDialog:Footer>
            <twig:AlertDialog:Cancel>Cancel</twig:AlertDialog:Cancel>
            <twig:AlertDialog:Action>Continue</twig:AlertDialog:Action>
        </twig:AlertDialog:Footer>
    </twig:AlertDialog:Content>
</twig:AlertDialog>

With Tooltip

Combining Dialog with Tooltip requires merging their *_trigger_attrs attributes using the html_attr_merge Twig filter, available in twig/html-extra:^3.24.

Loading...
<twig:AlertDialog id="delete_account_with_tooltip">
    <twig:AlertDialog:Trigger>
        <twig:Tooltip id="tooltip-alert-dialog">
            <twig:Tooltip:Trigger>
                <twig:Button variant="outline" {{ ...alert_dialog_trigger_attrs|html_attr_merge(tooltip_trigger_attrs) }}>
                    Delete Account
                </twig:Button>
            </twig:Tooltip:Trigger>
            <twig:Tooltip:Content>
                <p>This will permanently delete your account</p>
            </twig:Tooltip:Content>
        </twig:Tooltip>
    </twig:AlertDialog:Trigger>
    <twig:AlertDialog:Content>
        <twig:AlertDialog:Header>
            <twig:AlertDialog:Title>Are you absolutely sure?</twig:AlertDialog:Title>
            <twig:AlertDialog:Description>
                This action cannot be undone. This will permanently delete your
                account and remove your data from our servers.
            </twig:AlertDialog:Description>
        </twig:AlertDialog:Header>
        <twig:AlertDialog:Footer>
            <twig:AlertDialog:Cancel>Cancel</twig:AlertDialog:Cancel>
            <twig:AlertDialog:Action>Continue</twig:AlertDialog:Action>
        </twig:AlertDialog:Footer>
    </twig:AlertDialog:Content>
</twig:AlertDialog>

API Reference

AlertDialog

Prop Type Description
id string Unique identifier used to generate internal AlertDialog IDs
open boolean Whether the dialog is open on initial render. Defaults to false
Block Description
content The dialog structure, typically includes AlertDialog:Trigger and AlertDialog:Content

AlertDialog:Action

Prop Type Description
variant 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant. Defaults to default
Block Description
content The action button label

AlertDialog:Cancel

Block Description
content The cancel button label

AlertDialog:Content

Block Description
content The dialog content, typically includes AlertDialog:Header and AlertDialog:Footer

AlertDialog:Description

Block Description
content The descriptive text explaining the alert dialog purpose

AlertDialog:Footer

Block Description
content The footer area, typically contains AlertDialog:Cancel and AlertDialog:Action buttons

AlertDialog:Header

Block Description
content The header area, typically contains AlertDialog:Title and AlertDialog:Description

AlertDialog:Title

Block Description
content The title text of the alert dialog

AlertDialog:Trigger

Block Description
content The trigger element (e.g., a Button) that opens the dialog when clicked