View as Markdown

Toast

A succinct message that is displayed temporarily.

100%
Loading...
<div style="min-height: 240px">
    <twig:Toaster>
        <twig:Button
            variant="outline"
            data-action="click->toast#add"
            data-toast-title-param="Event created"
            data-toast-description-param="Sunday, December 3 at 9:00 AM"
            data-toast-action-label-param="Undo"
        >
            Show Toast
        </twig:Button>
    </twig:Toaster>
</div>

Installation

Note

Available since UX Toolkit 3.6.

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

Install the following Composer dependencies:

composer require symfony/ux-icons 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

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

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

const SWIPE_THRESHOLD = 45;
const SWIPE_START_SLOP = 4;
// The 500ms transform transition plus slack, so a dropped `transitionend` still removes the node.
const EXIT_FALLBACK_MS = 600;

let sequence = 0;

/**
 * Stacked toast notifications. The controller only publishes geometry, as custom properties
 * (`--toast-index`, `--toast-height`, `--toast-offset-y`, ...) and state attributes
 * (`data-expanded`, `data-behind`, `data-limited`, ...); the animations are pure CSS on top.
 *
 * @value  duration  Auto-dismiss delay in milliseconds for toasts that do not carry their own.
 * @value  limit     Maximum number of toasts kept on screen.
 * @target viewport  The region toasts are stacked into.
 * @target template  The markup cloned for every toast created from JavaScript.
 * @action add       Creates (or replaces) a toast from the `data-toast-*-param` attributes.
 * @action promise   Shows a loading toast, then replaces it with the success or error one.
 * @action close     Dismisses the toast the event originated from.
 * @action closeAll  Dismisses every toast at once.
 */
export default class extends Controller {
    static targets = ['viewport', 'template'];

    static values = {
        duration: { type: Number, default: 5000 },
        limit: { type: Number, default: 3 },
    };

    #timers = new Map(); // toast element → { id, remaining, start }
    #hydrated = new WeakSet();
    #swipeAborts = new Map(); // toast element → AbortController
    #expanded = false;
    #hovered = false;
    #focused = false;

    connect() {
        this.onToastEvent = (event) => this.#upsert(event.detail || {});
        window.addEventListener('toast', this.onToastEvent);

        // Tracked apart so neither cancels the other, and `focusout` checks `relatedTarget`:
        // tabbing between the action and close buttons stays inside the region.
        this.onPointerEnter = () => this.#track('hovered', true);
        this.onPointerLeave = () => this.#track('hovered', false);
        this.onFocusIn = () => this.#track('focused', true);
        this.onFocusOut = ({ relatedTarget }) => this.#track('focused', this.viewportTarget.contains(relatedTarget));
        this.onKeyDown = (event) => {
            const element = 'Escape' === event.key ? event.target.closest('[data-slot="toast"]') : null;
            if (element) {
                this.#dismiss(element, 'escape');
            }
        };
        this.viewportTarget.addEventListener('pointerenter', this.onPointerEnter);
        this.viewportTarget.addEventListener('pointerleave', this.onPointerLeave);
        this.viewportTarget.addEventListener('focusin', this.onFocusIn);
        this.viewportTarget.addEventListener('focusout', this.onFocusOut);
        this.viewportTarget.addEventListener('keydown', this.onKeyDown);

        for (const element of this.element.querySelectorAll('[data-slot="toast"]')) {
            if (!this.viewportTarget.contains(element)) {
                this.viewportTarget.appendChild(element);
            }
            this.#hydrate(element);
        }

        this.viewportObserver = new MutationObserver((records) => this.#absorb(records));
        this.viewportObserver.observe(this.viewportTarget, { childList: true });

        // After a move in the DOM, the toasts are already hydrated and their timers paused by `disconnect()`.
        this.#refreshInteraction();
        if (!this.#expanded) {
            this.#resumeTimers();
        }

        this.#layout();
    }

    disconnect() {
        window.removeEventListener('toast', this.onToastEvent);
        this.viewportTarget.removeEventListener('pointerenter', this.onPointerEnter);
        this.viewportTarget.removeEventListener('pointerleave', this.onPointerLeave);
        this.viewportTarget.removeEventListener('focusin', this.onFocusIn);
        this.viewportTarget.removeEventListener('focusout', this.onFocusOut);
        this.viewportTarget.removeEventListener('keydown', this.onKeyDown);
        this.viewportObserver.disconnect();

        this.#pauseTimers();

        for (const abort of this.#swipeAborts.values()) {
            abort.abort();
        }
        this.#swipeAborts.clear();
    }

    /**
     * Wired as `data-action="click->toast#add"`, configured with `data-toast-<name>-param`
     * attributes: `type`, `title`, `description`, `duration`, `id` and `actionLabel`.
     */
    add({ params = {} } = {}) {
        this.#upsert(params);
    }

    /**
     * Wired as `data-action="click->toast#promise"`, configured with `data-toast-<name>-param`
     * attributes: `loading`, `success`, `error`, `delay` and `reject`.
     */
    promise({ params = {} } = {}) {
        const { loading = '', success = '', error = '', delay = 2000, reject = false } = params;
        const id = `toast-promise-${++sequence}`;

        this.#upsert({ type: 'loading', title: loading, duration: 0, id });

        setTimeout(() => {
            this.#upsert(reject ? { type: 'error', title: error, id } : { type: 'success', title: success, id });
        }, Number(delay));
    }

    close({ target }) {
        const element = target.closest('[data-slot="toast"]');
        if (element) {
            this.#dismiss(element, target.closest('[data-slot="toast-action"]') ? 'action' : 'close');
        }
    }

    closeAll() {
        for (const element of this.#items()) {
            this.#dismiss(element, 'close');
        }
    }

    #upsert({ type = 'default', title = '', description = '', duration, id = null, actionLabel = '' } = {}) {
        const toastId = String(id ?? '').trim() || `toast-${++sequence}`;
        const existing = this.#items().find((element) => element.dataset.toastId === toastId);
        const element = existing ?? this.#clone();

        if (!element) {
            return;
        }

        const ms = Number(duration ?? this.durationValue);
        this.#fill(element, { type, title, description, actionLabel, toastId, ms });

        if (!existing) {
            this.viewportTarget.prepend(element);
            this.#hydrate(element);
        }

        this.#startTimer(element, ms);
        this.#layout();
    }

    #clone() {
        if (!this.hasTemplateTarget) {
            return null;
        }

        return this.templateTarget.content.firstElementChild?.cloneNode(true) ?? null;
    }

    #fill(element, { type, title, description, actionLabel, toastId, ms }) {
        element.dataset.type = type;
        element.dataset.toastId = toastId;
        element.dataset.toastDuration = String(ms);
        // Cleared again when a replaced toast leaves `error`, or a `promise` settling into `success`
        // under the same id would stay assertive.
        if ('error' === type) {
            element.setAttribute('role', 'alert');
            element.setAttribute('aria-live', 'assertive');
        } else {
            element.removeAttribute('role');
            element.removeAttribute('aria-live');
        }

        // Every status icon ships in the markup; only the one matching the type stays visible,
        // so a toast replaced by `promise` can still switch from `loading` to `success`.
        for (const icon of element.querySelectorAll('[data-slot="toast-icon"]')) {
            icon.hidden = icon.dataset.type !== type;
        }

        this.#text(element, 'toast-title', title);
        this.#text(element, 'toast-description', description);

        const action = element.querySelector('[data-slot="toast-action"]');
        if (action) {
            action.textContent = actionLabel;
            action.hidden = '' === actionLabel;
        }
    }

    #text(element, slot, value) {
        const node = element.querySelector(`[data-slot="${slot}"]`);
        if (node) {
            node.textContent = value;
            node.hidden = '' === value;
        }
    }

    #absorb(records) {
        const removed = new Set();
        let changed = false;

        for (const record of records) {
            for (const node of record.addedNodes) {
                if (Node.ELEMENT_NODE !== node.nodeType) {
                    continue;
                }

                const added = node.matches('[data-slot="toast"]')
                    ? [node]
                    : node.querySelectorAll('[data-slot="toast"]');
                for (const element of added) {
                    this.#hydrate(element);
                    changed = true;
                }
            }

            for (const node of record.removedNodes) {
                if (Node.ELEMENT_NODE !== node.nodeType) {
                    continue;
                }

                const gone = node.matches('[data-slot="toast"]')
                    ? [node]
                    : node.querySelectorAll('[data-slot="toast"]');
                for (const element of gone) {
                    removed.add(element);
                    changed = true;
                }
            }
        }

        // Records land once the DOM has settled, so a toast that was merely moved is back inside the
        // viewport by now: forgetting it here would clear its timer while `#hydrate()` skips it as
        // already hydrated, leaving a toast that never dismisses.
        for (const element of removed) {
            if (!this.viewportTarget.contains(element)) {
                this.#forget(element);
            }
        }

        if (removed.size > 0) {
            this.#refreshInteraction();
        }

        if (changed) {
            this.#layout();
        }
    }

    #hydrate(element) {
        if (this.#hydrated.has(element)) {
            return;
        }
        this.#hydrated.add(element);

        element.dataset.toastId ||= `toast-${++sequence}`;
        this.#reset(element);
        this.#watchSwipe(element);
        this.#enter(element);
        this.#startTimer(element, Number(element.dataset.toastDuration ?? this.durationValue));
    }

    // The counterpart to `#hydrate()`, for a toast that leaves without `#dismiss()`. Both maps are
    // keyed by the element, so skipping this keeps the node reachable and its timer still fires.
    #forget(element) {
        this.#hydrated.delete(element);
        this.#clearTimer(element);
        this.#swipeAborts.get(element)?.abort();
        this.#swipeAborts.delete(element);
    }

    #reset(element) {
        element.style.setProperty('--toast-swipe-movement-x', '0px');
        element.style.setProperty('--toast-swipe-movement-y', '0px');
    }

    #enter(element) {
        element.dataset.startingStyle = '';
        // Two frames: the first commits the starting transform, the second gives it something
        // to animate from.
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                delete element.dataset.startingStyle;
            });
        });
    }

    #dismiss(element, reason, direction = null) {
        if ('endingStyle' in element.dataset) {
            return;
        }

        this.#clearTimer(element);

        if (direction) {
            element.dataset.swipeDirection = direction;
        }
        element.dataset.endingStyle = '';

        let done = false;
        let fallbackId = null;
        const remove = () => {
            // `transitionend` and the fallback timer both land here; only the first one counts.
            if (done) {
                return;
            }
            done = true;
            clearTimeout(fallbackId);
            element.removeEventListener('transitionend', onTransitionEnd);

            const toastId = element.dataset.toastId;
            this.#forget(element);
            element.remove();

            this.dispatch('close', { detail: { id: toastId, reason } });
            this.#layout();
        };

        const onTransitionEnd = ({ propertyName, target }) => {
            if ('transform' === propertyName && target === element) {
                remove();
            }
        };

        element.addEventListener('transitionend', onTransitionEnd);
        fallbackId = setTimeout(remove, EXIT_FALLBACK_MS);
    }

    #items() {
        return Array.from(this.viewportTarget.querySelectorAll('[data-slot="toast"]')).filter(
            (element) => !('endingStyle' in element.dataset)
        );
    }

    #layout() {
        const items = this.#items();
        const frontmostHeight = items.length > 0 ? this.#measure(items[0]) : 0;
        let offset = 0;

        items.forEach((element, index) => {
            const height = this.#measure(element);

            element.style.setProperty('--toast-index', String(index));
            element.style.setProperty('--toast-height', `${height}px`);
            element.style.setProperty('--toast-frontmost-height', `${frontmostHeight}px`);
            element.style.setProperty('--toast-offset-y', `${offset}px`);
            offset += height;

            this.#toggle(element, 'expanded', this.#expanded);
            this.#toggle(element, 'limited', index >= this.limitValue);
            element.inert = index >= this.limitValue;

            const content = element.querySelector('[data-slot="toast-content"]');
            if (content) {
                this.#toggle(content, 'expanded', this.#expanded);
                this.#toggle(content, 'behind', !this.#expanded && index > 0);
            }
        });
    }

    // Natural height, read with the `--toast-height` constraint lifted. `offsetHeight` rather than
    // `getBoundingClientRect()`, which would fold in the `scale()` a stacked toast carries.
    #measure(element) {
        const previous = element.style.height;
        element.style.height = 'auto';
        const height = element.offsetHeight;
        element.style.height = previous;

        return height;
    }

    #toggle(element, name, on) {
        if (on) {
            element.dataset[name] = '';
        } else {
            delete element.dataset[name];
        }
    }

    #track(flag, on) {
        if ('hovered' === flag) {
            this.#hovered = on;
        } else {
            this.#focused = on;
        }

        this.#setExpanded(this.#hovered || this.#focused);
    }

    // Chrome and Safari fire no `focusout` when the focused element is removed (Firefox does), so
    // closing a toast from its own close button strands `#focused` and leaves every timer paused.
    // Hover is read off the toasts, not the `pointer-events-none` viewport, which never matches.
    #refreshInteraction() {
        this.#hovered = null !== this.viewportTarget.querySelector('[data-slot="toast"]:hover');
        this.#focused = this.viewportTarget.contains(document.activeElement);
        this.#setExpanded(this.#hovered || this.#focused);
    }

    #setExpanded(expanded) {
        if (this.#expanded === expanded) {
            return;
        }

        this.#expanded = expanded;
        if (expanded) {
            this.#pauseTimers();
        } else {
            this.#resumeTimers();
        }
        this.#layout();
    }

    #startTimer(element, ms) {
        this.#clearTimer(element);

        if (!(ms > 0)) {
            return;
        }

        const timer = { id: null, remaining: ms, start: Date.now() };
        this.#timers.set(element, timer);

        if (!this.#expanded) {
            timer.id = setTimeout(() => this.#dismiss(element, 'timeout'), ms);
        }
    }

    #clearTimer(element) {
        const timer = this.#timers.get(element);
        if (timer) {
            clearTimeout(timer.id);
            this.#timers.delete(element);
        }
    }

    #pauseTimers() {
        const now = Date.now();
        for (const timer of this.#timers.values()) {
            if (null !== timer.id) {
                clearTimeout(timer.id);
                timer.remaining = Math.max(0, timer.remaining - (now - timer.start));
                timer.id = null;
            }
        }
    }

    #resumeTimers() {
        const now = Date.now();
        for (const [element, timer] of this.#timers) {
            if (null === timer.id && timer.remaining > 0) {
                timer.start = now;
                timer.id = setTimeout(() => this.#dismiss(element, 'timeout'), timer.remaining);
            }
        }
    }

    #watchSwipe(element) {
        element.addEventListener('pointerdown', (event) => this.#startSwipe(event, element));
    }

    #startSwipe(pointerDownEvent, element) {
        if (0 !== pointerDownEvent.button || 'endingStyle' in element.dataset) {
            return;
        }

        // A re-entrant pointerdown (or a missed pointerup) must not leak the previous listeners.
        this.#swipeAborts.get(element)?.abort();

        const abort = new AbortController();
        const { signal } = abort;
        this.#swipeAborts.set(element, abort);

        const startX = pointerDownEvent.clientX;
        const startY = pointerDownEvent.clientY;
        let dragging = false;

        const endSwipe = () => {
            abort.abort();
            this.#swipeAborts.delete(element);
        };

        element.addEventListener(
            'pointermove',
            (event) => {
                const dx = event.clientX - startX;
                const dy = event.clientY - startY;

                if (!dragging) {
                    // Capture only once past the slop: a capture held at pointerup retargets the
                    // click to the toast, swallowing the button the press started on.
                    if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_START_SLOP) {
                        return;
                    }

                    dragging = true;
                    element.setPointerCapture(event.pointerId);
                }

                element.style.setProperty('--toast-swipe-movement-x', `${dx}px`);
                element.style.setProperty('--toast-swipe-movement-y', `${dy}px`);
            },
            { signal }
        );

        element.addEventListener(
            'pointerup',
            (event) => {
                endSwipe();

                if (!dragging) {
                    return;
                }

                if (element.hasPointerCapture(event.pointerId)) {
                    element.releasePointerCapture(event.pointerId);
                }

                const direction = this.#swipeDirection(event.clientX - startX, event.clientY - startY);
                if (direction) {
                    this.#dismiss(element, 'swipe', direction);
                } else {
                    this.#reset(element);
                }
            },
            { signal }
        );

        element.addEventListener(
            'pointercancel',
            () => {
                endSwipe();
                this.#reset(element);
            },
            { signal }
        );
    }

    #swipeDirection(dx, dy) {
        if (Math.max(Math.abs(dx), Math.abs(dy)) < SWIPE_THRESHOLD) {
            return null;
        }

        if (Math.abs(dx) >= Math.abs(dy)) {
            return dx > 0 ? 'right' : 'left';
        }

        return dy > 0 ? 'down' : 'up';
    }
}

/**
 * Creates a toast from anywhere in the page. Mirrors the options of the `toast#add` action.
 */
export function toast(options) {
    window.dispatchEvent(new CustomEvent('toast', { detail: options }));
}

toast.success = (title, options = {}) => toast({ type: 'success', title, ...options });
toast.info = (title, options = {}) => toast({ type: 'info', title, ...options });
toast.warning = (title, options = {}) => toast({ type: 'warning', title, ...options });
toast.error = (title, options = {}) => toast({ type: 'error', title, ...options });
toast.loading = (title, options = {}) => toast({ type: 'loading', title, duration: 0, ...options });

/**
 * Shows a loading toast and replaces it, under the same id, once the promise settles.
 */
toast.promise = (promise, { loading = '', success = '', error = '', id = null } = {}) => {
    const toastId = id ?? `toast-promise-${++sequence}`;
    toast({ type: 'loading', title: loading, duration: 0, id: toastId });

    return Promise.resolve(promise).then(
        (value) => {
            toast({
                type: 'success',
                title: 'function' === typeof success ? success(value) : success,
                id: toastId,
            });

            return value;
        },
        (reason) => {
            toast({
                type: 'error',
                title: 'function' === typeof error ? error(reason) : error,
                id: toastId,
            });

            throw reason;
        }
    );
};
templates/components/Toast.html.twig
{%- props
    ## 'default'|'success'|'info'|'warning'|'error'|'loading' The toast type, which selects the status icon and the announcement politeness.
    type = 'default',
    ## string|null Stable identifier; adding a toast with the same one replaces it instead of stacking a new one.
    id = null,
    ## int|null Auto-dismiss delay in milliseconds, where `0` keeps the toast until it is dismissed.
    duration = null
-%}
{%- set _toast_duration = duration ?? inject('toast.duration', 5000) -%}
{%- set _toast_hydrated = inject('toast.hydrated', false) -%}
<div
    data-slot="toast"
    data-type="{{ type }}"
    {%- if null is not same as id %} data-toast-id="{{ id }}"{% endif %}
    data-toast-duration="{{ _toast_duration }}"
    {%- if _toast_hydrated %} data-starting-style{% endif %}
    {%- if 'error' == type %} role="alert" aria-live="assertive"{% endif %}
    aria-atomic="true"
    {{ attributes.defaults({
        class: "group/toast pointer-events-auto absolute end-0 z-[calc(1000-var(--toast-index))] w-full rounded-2xl border bg-popover text-popover-foreground shadow-lg will-change-transform outline-none select-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [--gap:0.75rem] [--height:var(--toast-frontmost-height,var(--toast-height))] [--offset-y:calc((var(--toast-offset-y)+var(--toast-index)*var(--gap))*var(--toast-direction)+var(--toast-swipe-movement-y))] [--peek:0.75rem] [--scale:calc(max(0,1-(var(--toast-index)*0.1)))] [--shrink:calc(1-var(--scale))] h-(--height) [transform:translateX(var(--toast-swipe-movement-x))_translateY(calc(var(--toast-swipe-movement-y)+(var(--toast-index)*var(--peek)+var(--shrink)*var(--height))*var(--toast-direction)))_scale(var(--scale))] [transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] after:absolute after:start-0 after:h-[calc(var(--gap)+1px)] after:w-full after:content-[''] data-expanded:h-(--toast-height) data-expanded:[transform:translateX(var(--toast-swipe-movement-x))_translateY(var(--offset-y))] data-limited:opacity-0 data-starting-style:[transform:translateY(var(--toast-offscreen-y))] [&[data-ending-style]:not([data-limited]):not([data-swipe-direction])]:[transform:translateY(var(--toast-offscreen-y))] data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))] data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))] data-expanded:data-ending-style:data-[swipe-direction=down]:[transform:translateY(calc(var(--toast-swipe-movement-y)+150%))] data-expanded:data-ending-style:data-[swipe-direction=left]:[transform:translateX(calc(var(--toast-swipe-movement-x)-150%))_translateY(var(--offset-y))] data-expanded:data-ending-style:data-[swipe-direction=right]:[transform:translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--offset-y))] data-expanded:data-ending-style:data-[swipe-direction=up]:[transform:translateY(calc(var(--toast-swipe-movement-y)-150%))] bottom-0 origin-bottom after:top-full group-data-[side=top]/toast-viewport:top-0 group-data-[side=top]/toast-viewport:bottom-auto group-data-[side=top]/toast-viewport:origin-top group-data-[side=top]/toast-viewport:after:top-auto group-data-[side=top]/toast-viewport:after:bottom-full"|tailwind_classes,
    }) }}
>
    {##- The toast body, typically a `Toast:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Toast/Action.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The button style variant.
    variant = 'outline',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'sm',
    ## 'button'|'a' The HTML tag to render; use `a` when the action navigates.
    as = 'button'
-%}
<twig:Button as="{{ as }}" variant="{{ variant }}" size="{{ size }}" class="shrink-0" data-slot="toast-action" {{ ...attributes }}>
    {##- The action button label. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
templates/components/Toast/Close.html.twig
{%- props
    ## string Accessible label of the close button; translate it when your interface is not in English.
    label = 'Close toast'
-%}
<twig:Button
    variant="ghost"
    size="icon-sm"
    class="relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground"
    aria-label="{{ label }}"
    data-slot="toast-close"
    data-action="click->toast#close"
    {{ ...attributes }}
>
    <twig:ux:icon name="lucide:x" class="size-4" />
</twig:Button>
templates/components/Toast/Content.html.twig
<div
    data-slot="toast-content"
    {{ attributes.defaults({
        class: 'flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] data-behind:opacity-0 data-expanded:opacity-100'|tailwind_classes,
    }) }}
>
    {##- The toast content, typically a `Toast:Icon`, a `Toast:Title`, a `Toast:Description`, a `Toast:Action` and a `Toast:Close`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Toast/Description.html.twig
<div
    data-slot="toast-description"
    {{ attributes.defaults({
        class: 'text-sm text-muted-foreground'|tailwind_classes,
    }) }}
>
    {##- The secondary line describing the toast. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Toast/Icon.html.twig
{%- props
    ## 'default'|'success'|'info'|'warning'|'error'|'loading' The toast type whose status icon is rendered; `default` renders nothing.
    type = 'default'
-%}
{%- set _toast_icons = {
    success: {name: 'lucide:circle-check', class: 'size-4'},
    info: {name: 'lucide:info', class: 'size-4'},
    warning: {name: 'lucide:triangle-alert', class: 'size-4'},
    error: {name: 'lucide:octagon-x', class: 'size-4 text-destructive'},
    loading: {name: 'lucide:loader-2', class: 'size-4 animate-spin'},
} -%}
{%- set _toast_icon = _toast_icons[type] ?? null -%}
{%- if null is not same as _toast_icon -%}
    <span
        data-slot="toast-icon"
        data-type="{{ type }}"
        aria-hidden="true"
        {{ attributes.defaults({
            class: "shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"|tailwind_classes,
        }) }}
    >
        <twig:ux:icon name="{{ _toast_icon.name }}" class="{{ _toast_icon.class }}" />
    </span>
{%- endif -%}
templates/components/Toast/Title.html.twig
<div
    data-slot="toast-title"
    {{ attributes.defaults({
        class: 'text-sm font-medium'|tailwind_classes,
    }) }}
>
    {##- The toast title. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Toaster.html.twig
{%- props
    ## 'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right' Corner of the screen the toast region is anchored to.
    position = 'bottom-right',
    ## int Auto-dismiss delay in milliseconds applied to toasts that do not set their own, where `0` keeps them until dismissed.
    duration = 5000,
    ## int Maximum number of toasts kept on screen; the ones beyond it fade out until a slot frees up.
    limit = 3,
    ## string Accessible name of the toast region; translate it when your interface is not in English.
    label = 'Notifications'
-%}
{%- do provide('toast.duration', duration) -%}
{%- do provide('toast.hydrated', true) -%}
{%- set _toast_position = position|split('-') -%}
{%- set _toast_viewport_style = html_cva(
    base: 'group/toast-viewport pointer-events-none fixed inset-x-4 z-50 mx-auto w-auto max-w-sm outline-none sm:w-full',
    variants: {
        side: {
            top: 'top-4 [--toast-direction:1] [--toast-offscreen-y:-150%]',
            bottom: 'bottom-4 [--toast-direction:-1] [--toast-offscreen-y:150%]',
        },
        align: {
            left: 'sm:mx-0 sm:start-4 sm:end-auto',
            center: 'sm:inset-x-0 sm:mx-auto',
            right: 'sm:mx-0 sm:end-4 sm:start-auto',
        },
    },
) -%}
{# Consumed here so `class` lands on the visible region, whose placement it is meant to change, rather than on the host below. #}
{%- set _toast_viewport_class = (_toast_viewport_style.apply({side: _toast_position[0], align: _toast_position[1]}) ~ ' ' ~ attributes.render('class'))|tailwind_merge -%}
<div
    data-slot="toaster"
    data-position="{{ position }}"
    data-toast-duration-value="{{ duration }}"
    data-toast-limit-value="{{ limit }}"
    {{ attributes.defaults({
        'data-controller': 'toast',
    }) }}
>
    {# A live region has to be in the DOM before a node lands inside it, so `aria-live` belongs here
       rather than on a `Toast`, which would never be announced. `aria-atomic="false"` keeps a new
       toast from re-announcing the whole stack. -#}
    <div
        data-slot="toast-viewport"
        data-side="{{ _toast_position[0] }}"
        data-toast-target="viewport"
        role="region"
        aria-label="{{ label }}"
        aria-live="polite"
        aria-atomic="false"
        class="{{ _toast_viewport_class }}"
    >
        {##- The `Toast` items rendered on page load, for instance by looping over `app.flashes`. -#}
        {%- block toasts %}{% endblock -%}
    </div>
    <template data-toast-target="template">
        <twig:Toast>
            <twig:Toast:Content>
                <twig:Toast:Icon type="success" />
                <twig:Toast:Icon type="info" />
                <twig:Toast:Icon type="warning" />
                <twig:Toast:Icon type="error" />
                <twig:Toast:Icon type="loading" />
                <div class="flex min-w-0 flex-1 flex-col gap-1">
                    <twig:Toast:Title />
                    <twig:Toast:Description />
                </div>
                <twig:Toast:Action data-action="click->toast#close" />
                <twig:Toast:Close />
            </twig:Toast:Content>
        </twig:Toast>
    </template>
    {##- The trigger element(s) firing `toast#add`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Button.html.twig
{%- props
    ## 'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' The visual style variant.
    variant = 'default',
    ## 'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' The button size.
    size = 'default',
    ## 'button'|'a' The HTML tag to render; use `a` when the button navigates.
    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 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-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] 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 has-data-[icon=inline-end]:pe-2 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 has-data-[icon=inline-end]:pe-1.5 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 has-data-[icon=inline-end]:pe-1.5 has-data-[icon=inline-start]:ps-1.5 [&_svg:not([class*='size-'])]:size-3.5",
            lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pe-2 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="{{ attributes.render('data-slot')|default('button') }}"
    data-variant="{{ variant }}"
    data-size="{{ size }}"
    {{ attributes.defaults({
        class: style.apply({variant: variant, size: size})|tailwind_classes,
        type: as == 'button' ? 'button' : false,
    }) }}
>
    {##- The button label and/or icon. -#}
    {%- block content %}{% endblock -%}
</{{ as }}>
Note

If you installed the sonner recipe before Symfony UX Toolkit 3.6, your app already has a templates/components/Toast.html.twig file, which is Sonner's toast. Installing toast asks to overwrite that file, and overwriting it makes the <twig:Toast> tags you use with Sonner render this new component instead.

To keep both, move that file to templates/components/Sonner/Toast.html.twig before installing toast, then replace <twig:Toast with <twig:Sonner:Toast in your app's templates.

Usage

Render Toaster once, usually in your base layout, and set position to the corner the toasts should appear in. Then fire toasts from any element with a toast#add action, configured with data-toast-<name>-param attributes: type, title, description, duration, id and actionLabel.

<twig:Toaster position="top-left | top-center | top-right | bottom-left | bottom-center | bottom-right" duration="5000" limit="3">
    <twig:Button
        data-action="click->toast#add"
        data-toast-type-param="default | success | info | warning | error | loading"
        data-toast-title-param="Event created"
        data-toast-description-param="Sunday, December 3 at 9:00 AM"
        data-toast-action-label-param="Undo"
    >
        Show Toast
    </twig:Button>
</twig:Toaster>

The controller also exports a toast() helper, so application code can raise a toast without a trigger element: toast.success('Event created'), toast.error('Could not create event.'), or toast.promise(request, { loading: 'Creating event…', success: 'Event created.' }).

Examples

Types

Set type to render a status icon. The built-in renderer recognizes success, info, warning, error and loading.

100%
Loading...
<div style="min-height: 320px">
    <twig:Toaster>
        <div class="flex flex-wrap gap-2">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-description-param="Event has been created."
            >Default</twig:Button>
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-type-param="success"
                data-toast-description-param="Event has been created."
            >Success</twig:Button>
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-type-param="info"
                data-toast-description-param="Arrive 10 minutes before the event."
            >Info</twig:Button>
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-type-param="warning"
                data-toast-description-param="The event cannot start before 8:00 AM."
            >Warning</twig:Button>
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-type-param="error"
                data-toast-description-param="The event could not be created."
            >Error</twig:Button>
        </div>
    </twig:Toaster>
</div>

With Action

Pass actionLabel to render an action button. Clicking it dismisses the toast and emits a toast:close event whose detail carries the toast id and a reason of action, so your app can tell an "Undo" click apart from the other ways a toast closes: close, escape, swipe, and timeout.

100%
Loading...
<div style="min-height: 240px">
    <twig:Toaster>
        <twig:Button
            variant="outline"
            data-action="click->toast#add"
            data-toast-title-param="Event created"
            data-toast-description-param="You can undo this action."
            data-toast-action-label-param="Undo"
        >
            Show Toast
        </twig:Button>
    </twig:Toaster>
</div>

Promise

Use the toast#promise action to update one toast as an asynchronous task moves through loading, success and error states.

100%
Loading...
<div style="min-height: 280px">
    <twig:Toaster>
        <div class="flex flex-wrap gap-2">
            <twig:Button
                variant="outline"
                data-action="click->toast#promise"
                data-toast-loading-param="Creating event…"
                data-toast-success-param="Event created."
            >Create Event</twig:Button>
            <twig:Button
                variant="outline"
                data-action="click->toast#promise"
                data-toast-loading-param="Creating event…"
                data-toast-error-param="Could not create event."
                data-toast-reject-param="true"
            >Create Event (Failing)</twig:Button>
        </div>
    </twig:Toaster>
</div>

Position

Set position on Toaster to anchor the region to one of the six corners. Below the sm breakpoint the region spans the available width, so only the vertical side applies there.

100%
Loading...
<div style="min-height: 360px">
    <div class="flex flex-wrap justify-center gap-2">
        <twig:Toaster position="top-left">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Top left notification"
            >Top Left</twig:Button>
        </twig:Toaster>
        <twig:Toaster position="top-center">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Top center notification"
            >Top Center</twig:Button>
        </twig:Toaster>
        <twig:Toaster position="top-right">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Top right notification"
            >Top Right</twig:Button>
        </twig:Toaster>
        <twig:Toaster position="bottom-left">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Bottom left notification"
            >Bottom Left</twig:Button>
        </twig:Toaster>
        <twig:Toaster position="bottom-center">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Bottom center notification"
            >Bottom Center</twig:Button>
        </twig:Toaster>
        <twig:Toaster position="bottom-right">
            <twig:Button
                variant="outline"
                data-action="click->toast#add"
                data-toast-title-param="Bottom right notification"
            >Bottom Right</twig:Button>
        </twig:Toaster>
    </div>
</div>

Server Rendered

Fill the toasts block of Toaster to show items on page load, for instance by looping over app.flashes. Give them duration="0" to keep them until they are dismissed.

Toast items dropped into data-slot="toast-viewport" after the page has loaded, by a Turbo Stream for instance, are picked up and animated in the same way. Use prepend rather than append so the newest one lands in front of the stack.

100%
Loading...
<div style="min-height: 240px">
    <twig:Toaster>
        <twig:block name="toasts">
            <twig:Toast type="success" duration="0">
                <twig:Toast:Content>
                    <twig:Toast:Icon type="success" />
                    <div class="flex min-w-0 flex-1 flex-col gap-1">
                        <twig:Toast:Title>Profile saved</twig:Toast:Title>
                        <twig:Toast:Description>Your changes have been applied.</twig:Toast:Description>
                    </div>
                    <twig:Toast:Close />
                </twig:Toast:Content>
            </twig:Toast>
            <twig:Toast type="info" duration="0">
                <twig:Toast:Content>
                    <twig:Toast:Icon type="info" />
                    <div class="flex min-w-0 flex-1 flex-col gap-1">
                        <twig:Toast:Title>Welcome back, Alice!</twig:Toast:Title>
                    </div>
                    <twig:Toast:Close />
                </twig:Toast:Content>
            </twig:Toast>
        </twig:block>
    </twig:Toaster>
</div>

RTL

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

100%
Loading...
<div style="min-height: 300px">
    <twig:Toaster dir="rtl">
        <twig:block name="toasts">
            <twig:Toast type="success" duration="0">
                <twig:Toast:Content>
                    <twig:Toast:Icon type="success" />
                    <div class="flex min-w-0 flex-1 flex-col gap-1">
                        <twig:Toast:Title>تم إنشاء الحدث</twig:Toast:Title>
                        <twig:Toast:Description>الأحد ٣ ديسمبر الساعة ٩:٠٠ صباحًا</twig:Toast:Description>
                    </div>
                    <twig:Toast:Close label="إغلاق الإشعار" />
                </twig:Toast:Content>
            </twig:Toast>
        </twig:block>
    </twig:Toaster>
    <twig:Toaster dir="rtl" class="bottom-36">
        <twig:block name="toasts">
            <twig:Toast type="info" duration="0">
                <twig:Toast:Content>
                    <twig:Toast:Icon type="info" />
                    <div class="flex min-w-0 flex-1 flex-col gap-1">
                        <twig:Toast:Title>האירוע נוצר</twig:Toast:Title>
                        <twig:Toast:Description>יום ראשון, 3 בדצמבר בשעה 9:00</twig:Toast:Description>
                    </div>
                    <twig:Toast:Close label="סגור התראה" />
                </twig:Toast:Content>
            </twig:Toast>
        </twig:block>
    </twig:Toaster>
</div>

Accessibility

  • Toaster renders the toast region as a fixed container that is itself the live region (aria-live="polite"), so a toast is read out as it lands without stealing focus from whatever the user is doing. A live region has to be in the DOM before a node arrives inside it, which is why the aria-live sits on the region rather than on the toast: a toast carrying its own is never announced.
  • The region is a landmark (role="region") named by the label prop of Toaster, which defaults to Notifications. Translate it when your interface is not in English.
  • A Toast of type error escalates out of that politeness with role="alert" and aria-live="assertive", so a failure interrupts while ordinary confirmations wait their turn.
  • Each toast is aria-atomic="true", so its title and description are announced together rather than piecemeal, while the region is aria-atomic="false" so a new toast does not re-announce the whole stack.
  • The status icon is aria-hidden="true": it repeats what the text already says. Never rely on it alone to carry the meaning of a toast; write the type into the copy.
  • Toast:Close carries a translatable label prop, rendered as its aria-label. Translate it when your interface is not in English.
  • Auto-dismiss timers pause while the region is hovered or holds keyboard focus, so a toast cannot disappear from under someone tabbing into its action button.
  • Escape dismisses the toast that currently holds keyboard focus. Toasts beyond the limit stay inert, so Tab skips over them while they remain hidden.
  • Give duration enough time to read the toast, and do not put the only copy of important information in one. A toast disappears and cannot be recalled. An action inside it is reachable by tabbing, but only while it is on screen, so keep the same action available elsewhere in the page.

API Reference

<twig:Toast>

Prop Type Default
type 
'default'|'success'|'info'|'warning'|'error'|'loading' 'default'
id 
string|null null
duration 
int|null null
Block Description
content The toast body, typically a Toast:Content.

<twig:Toast:Action>

Prop Type Default
variant 
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' 'outline'
size 
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' 'sm'
as 
'button'|'a' 'button'
Block Description
content The action button label.

<twig:Toast:Close>

Prop Type Default
label 
string 'Close toast'

<twig:Toast:Content>

Block Description
content The toast content, typically a Toast:Icon, a Toast:Title, a Toast:Description, a Toast:Action and a Toast:Close.

<twig:Toast:Description>

Block Description
content The secondary line describing the toast.

<twig:Toast:Icon>

Prop Type Default
type 
'default'|'success'|'info'|'warning'|'error'|'loading' 'default'

<twig:Toast:Title>

Block Description
content The toast title.

<twig:Toaster>

Prop Type Default
position 
'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right' 'bottom-right'
duration 
int 5000
limit 
int 3
label 
string 'Notifications'
Block Description
toasts The Toast items rendered on page load, for instance by looping over app.flashes.
content The trigger element(s) firing toast#add.

data-controller="toast"

Value Type Default
data-toast-duration-value 
Number 5000
data-toast-limit-value 
Number 3
Target Description
viewport The region toasts are stacked into.
template The markup cloned for every toast created from JavaScript.
Action Description
add Creates (or replaces) a toast from the data-toast-*-param attributes.
promise Shows a loading toast, then replaces it with the success or error one.
close Dismisses the toast the event originated from.
closeAll Dismisses every toast at once.