View as Markdown

Sonner

A Sonner-style stacked toast notification system with auto-dismiss, swipe-to-dismiss, rich colors, and JS or server-render support.

Loading...
<twig:Sonner closeButton="true" expand="true">
    <div class="flex flex-wrap gap-2">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Event has been created"
            data-sonner-description-param="Sunday, December 03, 2023 at 9:00 AM"
        >Default</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="success"
            data-sonner-title-param="Profile updated successfully"
        >Success</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="error"
            data-sonner-title-param="Something went wrong"
            data-sonner-description-param="Please try again later."
        >Error</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="warning"
            data-sonner-title-param="Storage limit approaching"
            data-sonner-description-param="You have used 90% of your storage."
        >Warning</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="info"
            data-sonner-title-param="New update available"
        >Info</twig:Button>
    </div>
</twig:Sonner>

Installation

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

Install the following Composer dependencies:

composer require tales-from-a-dev/twig-tailwind-extra:^1.0.0

Copy the following file(s) into your app:

import { Controller } from &#039;@hotwired/stimulus&#039;;

// Literal class strings are required here so Tailwind JIT includes them in the CSS build.
const SONNER_BASE =
    &#039;group flex w-full items-start gap-3 rounded-lg border bg-background p-4 text-foreground shadow-lg transition-[transform,opacity] duration-300 pointer-events-auto absolute left-0 right-0&#039;;

const SONNER_RICH_COLORS = {
    success:
        &#039;bg-green-50 text-green-900 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-800&#039;,
    error: &#039;bg-red-50 text-red-900 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-800&#039;,
    warning:
        &#039;bg-amber-50 text-amber-900 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-800&#039;,
    info: &#039;bg-blue-50 text-blue-900 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-800&#039;,
};

const TYPE_ICONS = {
    success:
        &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M22 11.08V12a10 10 0 1 1-5.93-9.14&quot;/&gt;&lt;polyline points=&quot;22 4 12 14.01 9 11.01&quot;/&gt;&lt;/svg&gt;&#039;,
    error: &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;10&quot;/&gt;&lt;line x1=&quot;15&quot; y1=&quot;9&quot; x2=&quot;9&quot; y2=&quot;15&quot;/&gt;&lt;line x1=&quot;9&quot; y1=&quot;9&quot; x2=&quot;15&quot; y2=&quot;15&quot;/&gt;&lt;/svg&gt;&#039;,
    warning:
        &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z&quot;/&gt;&lt;path d=&quot;M12 9v4&quot;/&gt;&lt;path d=&quot;M12 17h.01&quot;/&gt;&lt;/svg&gt;&#039;,
    info: &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;circle cx=&quot;12&quot; cy=&quot;12&quot; r=&quot;10&quot;/&gt;&lt;path d=&quot;M12 16v-4&quot;/&gt;&lt;path d=&quot;M12 8h.01&quot;/&gt;&lt;/svg&gt;&#039;,
    loading:
        &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;16&quot; height=&quot;16&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; class=&quot;animate-spin&quot; aria-hidden=&quot;true&quot;&gt;&lt;path d=&quot;M21 12a9 9 0 1 1-6.219-8.56&quot;/&gt;&lt;/svg&gt;&#039;,
};

const CLOSE_ICON =
    &#039;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;line x1=&quot;18&quot; y1=&quot;6&quot; x2=&quot;6&quot; y2=&quot;18&quot;/&gt;&lt;line x1=&quot;6&quot; y1=&quot;6&quot; x2=&quot;18&quot; y2=&quot;18&quot;/&gt;&lt;/svg&gt;&#039;;

export default class extends Controller {
    static values = {
        position: { type: String, default: &#039;top-right&#039; },
        expand: { type: Boolean, default: false },
        richColors: { type: Boolean, default: false },
        closeButton: { type: Boolean, default: false },
        duration: { type: Number, default: 4000 },
        gap: { type: Number, default: 14 },
        visibleToasts: { type: Number, default: 3 },
        dir: { type: String, default: &#039;ltr&#039; },
        theme: { type: String, default: &#039;system&#039; },
    };

    static targets = [&#039;list&#039;];

    #swipeAborts = new WeakMap(); // li → AbortController

    connect() {
        this.toastSeq = 0;
        this.timers = new Map(); // li → { id, remaining, start }
        this.isHovered = false;

        this.onSonnerEvent = (e) =&gt; this.#createToast(e.detail || {});
        window.addEventListener(&#039;sonner&#039;, this.onSonnerEvent);

        this.#applyTheme();

        // Server-rendered toasts sit outside the &lt;ol&gt; in the DOM; move them in before hydrating
        for (const li of this.element.querySelectorAll(&#039;[data-slot=&quot;toast&quot;]&#039;)) {
            this.listTarget.appendChild(li);
            this.#hydrateToast(li);
        }

        this.onMouseOver = (e) =&gt; {
            if (!this.isHovered &amp;&amp; e.target.closest(&#039;[data-slot=&quot;toast&quot;]&#039;)) {
                this.isHovered = true;
                this.#pauseAll();
                this.#layout();
            }
        };
        this.onMouseOut = (e) =&gt; {
            const related = e.relatedTarget;
            if (this.isHovered &amp;&amp; (!related || !this.element.contains(related))) {
                this.isHovered = false;
                this.#resumeAll();
                this.#layout();
            }
        };

        this.listTarget.addEventListener(&#039;mouseover&#039;, this.onMouseOver);
        this.listTarget.addEventListener(&#039;mouseout&#039;, this.onMouseOut);

        this.#layout();
    }

    disconnect() {
        window.removeEventListener(&#039;sonner&#039;, this.onSonnerEvent);
        this.listTarget.removeEventListener(&#039;mouseover&#039;, this.onMouseOver);
        this.listTarget.removeEventListener(&#039;mouseout&#039;, this.onMouseOut);
        for (const timer of this.timers.values()) {
            clearTimeout(timer.id);
        }
        this.timers.clear();
    }

    // Called via data-action=&quot;click-&gt;sonner#fire&quot; with data-sonner-*-param attributes.
    fire({
        params: {
            type = &#039;default&#039;,
            title = &#039;&#039;,
            description = null,
            duration = null,
            actionLabel = null,
            actionUrl = null,
        } = {},
    }) {
        const action = actionLabel ? { label: actionLabel, ...(actionUrl &amp;&amp; { url: actionUrl }) } : null;
        this.#createToast({ type, title, description, action, ...(duration !== null &amp;&amp; { duration }) });
    }

    // ─── Private ─────────────────────────────────────────────────────────────

    get #isTopPosition() {
        return this.positionValue.startsWith(&#039;top&#039;);
    }

    #applyTheme() {
        if (this.themeValue === &#039;dark&#039;) {
            this.element.dataset.theme = &#039;dark&#039;;
        } else if (this.themeValue === &#039;light&#039;) {
            this.element.dataset.theme = &#039;light&#039;;
        }
    }

    #createToast({
        type = &#039;default&#039;,
        title = &#039;&#039;,
        description = null,
        duration,
        closeButton,
        id = null,
        action = null,
    } = {}) {
        const toastId = id ?? `toast-${++this.toastSeq}`;
        const toastMs = duration ?? this.durationValue;
        const showClose = closeButton ?? this.closeButtonValue;

        const existing = this.listTarget.querySelector(`[data-toast-id=&quot;${CSS.escape(toastId)}&quot;]`);
        if (existing) this.#remove(existing);

        const li = document.createElement(&#039;li&#039;);
        li.dataset.slot = &#039;toast&#039;;
        li.dataset.type = type;
        li.dataset.state = &#039;closed&#039;;
        li.dataset.toastId = toastId;
        li.setAttribute(&#039;role&#039;, type === &#039;error&#039; ? &#039;alert&#039; : &#039;status&#039;);
        li.setAttribute(&#039;aria-live&#039;, type === &#039;error&#039; ? &#039;assertive&#039; : &#039;polite&#039;);
        li.setAttribute(&#039;aria-atomic&#039;, &#039;true&#039;);

        li.className = SONNER_BASE;
        if (this.richColorsValue &amp;&amp; SONNER_RICH_COLORS[type]) {
            li.className += &#039; &#039; + SONNER_RICH_COLORS[type];
        }

        if (TYPE_ICONS[type]) {
            const icon = document.createElement(&#039;div&#039;);
            icon.dataset.slot = &#039;toast-icon&#039;;
            icon.className = &#039;mt-0.5 shrink-0 self-start&#039;;
            icon.innerHTML = TYPE_ICONS[type]; // trusted constant SVG string
            li.appendChild(icon);
        }

        const content = document.createElement(&#039;div&#039;);
        content.dataset.slot = &#039;toast-content&#039;;
        content.className = &#039;flex-1 space-y-1&#039;;

        const titleEl = document.createElement(&#039;div&#039;);
        titleEl.dataset.slot = &#039;toast-title&#039;;
        titleEl.className = &#039;text-sm font-semibold leading-snug&#039;;
        titleEl.textContent = title;
        content.appendChild(titleEl);

        if (description) {
            const desc = document.createElement(&#039;div&#039;);
            desc.dataset.slot = &#039;toast-description&#039;;
            desc.className = &#039;text-sm text-muted-foreground&#039;;
            desc.textContent = description;
            content.appendChild(desc);
        }

        li.appendChild(content);

        if (action?.label) {
            const btn = action.url ? document.createElement(&#039;a&#039;) : document.createElement(&#039;button&#039;);

            if (action.url) btn.href = action.url;
            else btn.type = &#039;button&#039;;
            if (typeof action.onClick === &#039;function&#039;) {
                btn.addEventListener(&#039;click&#039;, action.onClick);
            }

            btn.dataset.slot = &#039;toast-action&#039;;
            btn.className =
                &#039;ml-auto shrink-0 rounded-md border bg-transparent px-3 py-1 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2&#039;;
            btn.textContent = action.label;
            li.appendChild(btn);
        }

        if (showClose) this.#appendCloseButton(li);

        this.listTarget.prepend(li);
        this.#activateToast(li, type, toastMs, true);
    }

    #hydrateToast(li) {
        const type = li.dataset.type ?? &#039;default&#039;;
        const ms = li.dataset.duration ? Number(li.dataset.duration) : this.durationValue;

        if (this.richColorsValue &amp;&amp; SONNER_RICH_COLORS[type] &amp;&amp; !li.dataset.richApplied) {
            li.className += &#039; &#039; + SONNER_RICH_COLORS[type];
            li.dataset.richApplied = &#039;1&#039;;
        }

        const closeBtn = li.querySelector(&#039;[data-slot=&quot;toast-close&quot;]&#039;);
        if (closeBtn) {
            closeBtn.addEventListener(&#039;click&#039;, () =&gt; this.#dismiss(li));
        } else if (this.closeButtonValue) {
            this.#appendCloseButton(li);
        }

        this.#activateToast(li, type, ms, true);
    }

    #activateToast(li, type, ms, layoutOnOpen = false) {
        this.#anchor(li);
        li.addEventListener(&#039;pointerdown&#039;, (e) =&gt; this.#startSwipe(e, li));
        requestAnimationFrame(() =&gt; {
            li.dataset.state = &#039;open&#039;;
            if (layoutOnOpen) this.#layout();
        });
        if (type !== &#039;loading&#039; &amp;&amp; ms &gt; 0) this.#startTimer(li, ms);
    }

    #appendCloseButton(li) {
        const btn = document.createElement(&#039;button&#039;);
        btn.type = &#039;button&#039;;
        btn.dataset.slot = &#039;toast-close&#039;;
        btn.className =
            &#039;absolute top-2 right-2 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-muted&#039;;
        btn.setAttribute(&#039;aria-label&#039;, &#039;Close notification&#039;);
        btn.innerHTML = CLOSE_ICON;
        btn.addEventListener(&#039;click&#039;, () =&gt; this.#dismiss(li));
        li.appendChild(btn);
    }

    #anchor(li) {
        const isTop = this.#isTopPosition;
        li.style.position = &#039;absolute&#039;;
        li.style.left = &#039;0&#039;;
        li.style.right = &#039;0&#039;;
        li.style.opacity = &#039;0&#039;;

        if (isTop) {
            li.style.top = &#039;0&#039;;
            li.style.bottom = &#039;auto&#039;;
        } else {
            li.style.bottom = &#039;0&#039;;
            li.style.top = &#039;auto&#039;;
        }
    }

    #dismiss(li) {
        if (li.dataset.state === &#039;closing&#039;) return;
        this.#clearTimer(li);

        li.dataset.state = &#039;closing&#039;;
        li.style.opacity = &#039;0&#039;;

        const isTop = this.#isTopPosition;
        li.style.transform = isTop ? &#039;translateY(-16px) scale(0.95)&#039; : &#039;translateY(16px) scale(0.95)&#039;;

        let done = false;
        let fallbackId = null;
        const cleanup = () =&gt; {
            if (done) return; // transitionend and the fallback timer both call this; run once
            done = true;
            if (fallbackId !== null) clearTimeout(fallbackId);
            li.removeEventListener(&#039;transitionend&#039;, onTransitionEnd);
            this.#remove(li);
            li.dispatchEvent(
                new CustomEvent(&#039;sonner:dismiss&#039;, {
                    bubbles: true,
                    detail: { id: li.dataset.toastId },
                })
            );
            this.#layout();
        };

        const onTransitionEnd = ({ propertyName }) =&gt; {
            if (propertyName === &#039;opacity&#039;) cleanup();
        };

        li.addEventListener(&#039;transitionend&#039;, onTransitionEnd);
        fallbackId = setTimeout(cleanup, 400); // fallback
    }

    #remove(li) {
        this.#clearTimer(li);
        this.#swipeAborts.get(li)?.abort();
        if (li.parentNode) li.remove();
    }

    #layout() {
        const openToasts = Array.from(this.listTarget.querySelectorAll(&#039;[data-slot=&quot;toast&quot;][data-state=&quot;open&quot;]&#039;));

        const isTop = this.#isTopPosition;
        const gap = this.gapValue;
        const maxVisible = this.visibleToastsValue;
        const isExpanded = this.isHovered &amp;&amp; this.expandValue;

        let runningOffset = 0;

        openToasts.forEach((li, idx) =&gt; {
            if (idx &gt;= maxVisible) {
                li.style.opacity = &#039;0&#039;;
                li.style.pointerEvents = &#039;none&#039;;
                li.style.zIndex = &#039;0&#039;;
                return;
            }

            li.style.pointerEvents = isExpanded || idx === 0 ? &#039;auto&#039; : &#039;none&#039;;
            li.style.zIndex = String(maxVisible - idx);
            li.style.transformOrigin = isTop ? &#039;top center&#039; : &#039;bottom center&#039;;

            if (isExpanded) {
                const dir = isTop ? 1 : -1;
                li.style.transform = `translateY(${dir * runningOffset}px)`;
                li.style.opacity = &#039;1&#039;;
                runningOffset += (li.offsetHeight || 56) + gap;
            } else {
                const offset = idx * gap;
                const scale = 1 - idx * 0.05;
                const opacity = Math.max(0, 1 - idx * 0.33);
                const dir = isTop ? 1 : -1;
                li.style.transform = `translateY(${dir * offset}px) scale(${scale})`;
                li.style.opacity = opacity;
            }
        });
    }

    #startTimer(li, ms) {
        const id = setTimeout(() =&gt; this.#dismiss(li), ms);
        this.timers.set(li, { id, remaining: ms, start: Date.now() });
    }

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

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

    #resumeAll() {
        const now = Date.now();
        for (const [li, timer] of this.timers) {
            if (timer.id === null &amp;&amp; timer.remaining &gt; 0) {
                timer.start = now;
                timer.id = setTimeout(() =&gt; this.#dismiss(li), timer.remaining);
            }
        }
    }

    #startSwipe(pointerDownEvent, li) {
        // Abort any in-flight swipe for this toast before starting a new one, so a
        // re-entrant pointerdown (or a missed pointerup) can&#039;t leak the old listeners.
        this.#swipeAborts.get(li)?.abort();

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

        const startX = pointerDownEvent.clientX;
        li.setPointerCapture(pointerDownEvent.pointerId);

        const endSwipe = () =&gt; {
            ac.abort();
            this.#swipeAborts.delete(li);
        };

        li.addEventListener(
            &#039;pointermove&#039;,
            (e) =&gt; {
                const dx = e.clientX - startX;
                const rtl = this.dirValue === &#039;rtl&#039;;
                if ((!rtl &amp;&amp; dx &gt; 0) || (rtl &amp;&amp; dx &lt; 0)) {
                    li.style.transform = `translateX(${dx}px)`;
                    li.style.opacity = String(Math.max(0, 1 - Math.abs(dx) / 200));
                }
            },
            { signal }
        );

        li.addEventListener(
            &#039;pointerup&#039;,
            (e) =&gt; {
                const dx = e.clientX - startX;
                endSwipe();
                if (Math.abs(dx) &gt; 80) {
                    this.#dismiss(li);
                } else {
                    this.#layout();
                }
            },
            { signal }
        );

        li.addEventListener(
            &#039;pointercancel&#039;,
            () =&gt; {
                endSwipe();
                this.#layout();
            },
            { signal }
        );
    }
}

export function toast(options) {
    window.dispatchEvent(new CustomEvent(&#039;sonner&#039;, { detail: options }));
}

toast.success = (title, opts = {}) =&gt; toast({ type: &#039;success&#039;, title, ...opts });
toast.error = (title, opts = {}) =&gt; toast({ type: &#039;error&#039;, title, ...opts });
toast.warning = (title, opts = {}) =&gt; toast({ type: &#039;warning&#039;, title, ...opts });
toast.info = (title, opts = {}) =&gt; toast({ type: &#039;info&#039;, title, ...opts });
toast.loading = (title, opts = {}) =&gt; toast({ type: &#039;loading&#039;, title, ...opts });
{# @prop position &#039;top-left&#039;|&#039;top-center&#039;|&#039;top-right&#039;|&#039;bottom-left&#039;|&#039;bottom-center&#039;|&#039;bottom-right&#039; Position of the notification region. #}
{# @prop expand boolean Whether toasts expand on hover instead of stacking. #}
{# @prop richColors boolean Whether to use semantic colors for toast types. #}
{# @prop closeButton boolean Whether to show a close button on every toast. #}
{# @prop duration int Auto-dismiss duration in milliseconds. #}
{# @prop gap int Pixel gap between stacked toasts. #}
{# @prop visibleToasts int Maximum number of visible stacked toasts. #}
{# @prop dir &#039;ltr&#039;|&#039;rtl&#039;|&#039;auto&#039; Text direction. #}
{# @prop theme &#039;light&#039;|&#039;dark&#039;|&#039;system&#039; Color theme. #}
{# @block content Optional trigger button(s) or server-rendered `&lt;twig:Toast&gt;` components. #}
{%- props position = &#039;top-right&#039;, expand = false, richColors = false, closeButton = false, duration = 4000, gap = 14, visibleToasts = 3, dir = &#039;ltr&#039;, theme = &#039;system&#039; -%}
{%- set _positionClass = {
    &#039;top-left&#039;: &#039;top-0 left-0&#039;,
    &#039;top-center&#039;: &#039;top-0 left-1/2 -translate-x-1/2&#039;,
    &#039;top-right&#039;: &#039;top-0 right-0&#039;,
    &#039;bottom-left&#039;: &#039;bottom-0 left-0&#039;,
    &#039;bottom-center&#039;: &#039;bottom-0 left-1/2 -translate-x-1/2&#039;,
    &#039;bottom-right&#039;: &#039;bottom-0 right-0&#039;,
}[position] -%}
{# Consume the consumer-supplied `class` here so it lands on the visible `&lt;ol&gt;` region, not the structural host, and is not re-emitted by `attributes.defaults()` below. #}
{%- set _listClass = (&#039;fixed z-[100] m-1 h-0 w-[360px] list-none overflow-visible p-4 &#039; ~ _positionClass ~ &#039; &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge -%}
&lt;div
    data-slot=&quot;sonner&quot;
    dir=&quot;{{ dir }}&quot;
    data-sonner-position-value=&quot;{{ position }}&quot;
    data-sonner-expand-value=&quot;{{ expand ? &#039;true&#039; : &#039;false&#039; }}&quot;
    data-sonner-rich-colors-value=&quot;{{ richColors ? &#039;true&#039; : &#039;false&#039; }}&quot;
    data-sonner-close-button-value=&quot;{{ closeButton ? &#039;true&#039; : &#039;false&#039; }}&quot;
    data-sonner-duration-value=&quot;{{ duration }}&quot;
    data-sonner-gap-value=&quot;{{ gap }}&quot;
    data-sonner-visible-toasts-value=&quot;{{ visibleToasts }}&quot;
    data-sonner-dir-value=&quot;{{ dir }}&quot;
    data-sonner-theme-value=&quot;{{ theme }}&quot;
    {{ attributes.defaults({&#039;data-controller&#039;: &#039;sonner&#039;}) }}
&gt;
    &lt;section
        aria-label=&quot;Notifications&quot;
        aria-live=&quot;polite&quot;
    &gt;
        &lt;ol
            class=&quot;{{ _listClass }}&quot;
            style=&quot;pointer-events: none;&quot;
            data-sonner-target=&quot;list&quot;
            tabindex=&quot;-1&quot;
        &gt;
        &lt;/ol&gt;
    &lt;/section&gt;
    {%- block content %}{% endblock -%}
&lt;/div&gt;
{# @prop type &#039;default&#039;|&#039;success&#039;|&#039;error&#039;|&#039;warning&#039;|&#039;info&#039;|&#039;loading&#039; The toast type. #}
{# @prop title string The toast title. Required. #}
{# @prop description string|null The secondary description line. #}
{# @prop duration int|null Per-toast auto-dismiss override in ms. Uses the Toaster default when `null`. #}
{# @prop closeButton boolean Whether to show a close button. #}
{# @prop id string|null Stable ID for deduplication. Auto-generated when `null`. #}
{# @prop action array|null Action link with `label` and `url` keys. #}
{%- props type = &#039;default&#039;, title = &#039;&#039;, description = null, duration = null, closeButton = false, id = null, action = null -%}
&lt;li
    class=&quot;{{ (&#039;group flex w-full items-start gap-3 rounded-lg border bg-background p-4 text-foreground shadow-lg transition-[transform,opacity] duration-300 pointer-events-auto absolute left-0 right-0 &#039; ~ attributes.render(&#039;class&#039;))|tailwind_merge }}&quot;
    data-slot=&quot;toast&quot;
    data-state=&quot;closed&quot;
    data-type=&quot;{{ type }}&quot;
    role=&quot;{{ type == &#039;error&#039; ? &#039;alert&#039; : &#039;status&#039; }}&quot;
    aria-live=&quot;{{ type == &#039;error&#039; ? &#039;assertive&#039; : &#039;polite&#039; }}&quot;
    aria-atomic=&quot;true&quot;
    {%- if id is not null %} data-toast-id=&quot;{{ id }}&quot;{% endif %}
    {%- if duration is not null %} data-duration=&quot;{{ duration }}&quot;{% endif %}
    {{ attributes }}
&gt;
    &lt;div data-slot=&quot;toast-content&quot; class=&quot;flex-1 space-y-1&quot;&gt;
        &lt;div data-slot=&quot;toast-title&quot; class=&quot;text-sm font-semibold leading-snug&quot;&gt;{{ title }}&lt;/div&gt;
        {%- if description %}
            &lt;div data-slot=&quot;toast-description&quot; class=&quot;text-sm text-muted-foreground&quot;&gt;{{ description }}&lt;/div&gt;
        {%- endif %}
    &lt;/div&gt;
    {%- if action %}
        &lt;a
            href=&quot;{{ action.url ?? &#039;#&#039; }}&quot;
            data-slot=&quot;toast-action&quot;
            class=&quot;ml-auto shrink-0 rounded-md border bg-transparent px-3 py-1 text-sm font-medium transition-colors hover:bg-secondary focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2&quot;
        &gt;{{ action.label }}&lt;/a&gt;
    {%- endif %}
    {%- if closeButton %}
        &lt;button
            type=&quot;button&quot;
            data-slot=&quot;toast-close&quot;
            aria-label=&quot;Close notification&quot;
            class=&quot;absolute top-2 right-2 rounded-md p-1 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-muted&quot;
        &gt;&lt;svg xmlns=&quot;http://www.w3.org/2000/svg&quot; width=&quot;14&quot; height=&quot;14&quot; viewBox=&quot;0 0 24 24&quot; fill=&quot;none&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2&quot; stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; aria-hidden=&quot;true&quot;&gt;&lt;line x1=&quot;18&quot; y1=&quot;6&quot; x2=&quot;6&quot; y2=&quot;18&quot;/&gt;&lt;line x1=&quot;6&quot; y1=&quot;6&quot; x2=&quot;18&quot; y2=&quot;18&quot;/&gt;&lt;/svg&gt;&lt;/button&gt;
    {%- endif %}
&lt;/li&gt;

Usage

<twig:Sonner>
    <twig:Button
        data-action="click->sonner#fire"
        data-sonner-title-param="Event has been created"
    >
        Show Sonner
    </twig:Button>
</twig:Sonner>

Examples

Types

Loading...
<twig:Sonner>
    <div class="flex flex-wrap gap-2">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Default notification"
        >Default</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="success"
            data-sonner-title-param="Changes saved"
        >Success</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="error"
            data-sonner-title-param="Failed to save changes"
        >Error</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="warning"
            data-sonner-title-param="Unsaved changes"
        >Warning</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="info"
            data-sonner-title-param="New message received"
        >Info</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="loading"
            data-sonner-title-param="Uploading file…"
            data-sonner-duration-param="0"
        >Loading</twig:Button>
    </div>
</twig:Sonner>

With Description

Loading...
<twig:Sonner>
    <twig:Button variant="outline"
        data-action="click->sonner#fire"
        data-sonner-title-param="Event has been created"
        data-sonner-description-param="Sunday, December 03, 2023 at 9:00 AM"
    >
        Show Sonner
    </twig:Button>
</twig:Sonner>

With Action

Loading...
<twig:Sonner>
    <twig:Button variant="outline"
        data-action="click->sonner#fire"
        data-sonner-title-param="Event has been created"
        data-sonner-action-label-param="Undo"
        data-sonner-action-url-param="#"
    >
        Show Sonner
    </twig:Button>
</twig:Sonner>

Rich Colors

Loading...
<twig:Sonner richColors="true">
    <div class="flex flex-wrap gap-2">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="success"
            data-sonner-title-param="Profile updated successfully"
        >Success</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="error"
            data-sonner-title-param="Something went wrong"
        >Error</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="warning"
            data-sonner-title-param="Storage limit approaching"
        >Warning</twig:Button>
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-type-param="info"
            data-sonner-title-param="New update available"
        >Info</twig:Button>
    </div>
</twig:Sonner>

Position

Loading...
<div class="flex flex-wrap justify-center gap-2">
    <twig:Sonner position="top-left">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Top-left notification"
        >
            Top Left
        </twig:Button>
    </twig:Sonner>
    <twig:Sonner position="top-center">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Top-center notification"
        >
            Top Center
        </twig:Button>
    </twig:Sonner>
    <twig:Sonner position="top-right">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Top-right notification"
        >
            Top Right
        </twig:Button>
    </twig:Sonner>
    <twig:Sonner position="bottom-left">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Bottom-left notification"
        >
            Bottom Left
        </twig:Button>
    </twig:Sonner>
    <twig:Sonner position="bottom-center">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Bottom-center notification"
        >
            Bottom Center
        </twig:Button>
    </twig:Sonner>
    <twig:Sonner position="bottom-right">
        <twig:Button variant="outline"
            data-action="click->sonner#fire"
            data-sonner-title-param="Bottom-right notification"
        >
            Bottom Right
        </twig:Button>
    </twig:Sonner>
</div>

Server Rendered

Loading...
<twig:Sonner closeButton="true">
    <twig:Toast type="success" title="Profile saved" description="Your changes have been applied." duration="0" />
    <twig:Toast type="info" title="Welcome back, Alice!" duration="0" />
</twig:Sonner>

API Reference

<twig:Sonner>

Prop Type Default
position 
'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right' 'top-right'
expand 
boolean false
richColors 
boolean false
closeButton 
boolean false
duration 
int 4000
gap 
int 14
visibleToasts 
int 3
dir 
'ltr'|'rtl'|'auto' 'ltr'
theme 
'light'|'dark'|'system' 'system'
Block Description
content Optional trigger button(s) or server-rendered <twig:Toast> components.

<twig:Toast>

Prop Type Default
type 
'default'|'success'|'error'|'warning'|'info'|'loading' 'default'
title 
string ''
description 
string|null null
duration 
int|null null
closeButton 
boolean false
id 
string|null null
action 
array|null null