Toast
A succinct message that is displayed temporarily.
<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
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:
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;
}
);
};
{%- 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>
{%- 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>
{%- 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>
<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>
<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>
{%- 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 -%}
<div
data-slot="toast-title"
{{ attributes.defaults({
class: 'text-sm font-medium'|tailwind_classes,
}) }}
>
{##- The toast title. -#}
{%- block content %}{% endblock -%}
</div>
{%- 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>
{%- 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 }}>
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.
<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.
<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.
<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.
<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.
<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.
<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
Toasterrenders 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 thearia-livesits 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 thelabelprop ofToaster, which defaults toNotifications. Translate it when your interface is not in English. - A
Toastof typeerrorescalates out of that politeness withrole="alert"andaria-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 isaria-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:Closecarries a translatablelabelprop, rendered as itsaria-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
limitstayinert, so Tab skips over them while they remain hidden. - Give
durationenough 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 The toast type, which selects the status icon and the announcement politeness.
|
'default'|'success'|'info'|'warning'|'error'|'loading' |
'default' |
id Stable identifier; adding a toast with the same one replaces it instead of stacking a new one.
|
string|null |
null |
duration Auto-dismiss delay in milliseconds, where
0 keeps the toast until it is dismissed. |
int|null |
null |
| Block | Description |
|---|---|
content |
The toast body, typically a Toast:Content. |
<twig:Toast:Action>
| Prop | Type | Default |
|---|---|---|
variant The button style variant.
|
'default'|'secondary'|'destructive'|'outline'|'ghost'|'link' |
'outline' |
size The button size.
|
'default'|'xs'|'sm'|'lg'|'icon'|'icon-xs'|'icon-sm'|'icon-lg' |
'sm' |
as The HTML tag to render; use
a when the action navigates. |
'button'|'a' |
'button' |
| Block | Description |
|---|---|
content |
The action button label. |
<twig:Toast:Close>
| Prop | Type | Default |
|---|---|---|
label Accessible label of the close button; translate it when your interface is not in English.
|
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 The toast type whose status icon is rendered;
default renders nothing. |
'default'|'success'|'info'|'warning'|'error'|'loading' |
'default' |
<twig:Toast:Title>
| Block | Description |
|---|---|
content |
The toast title. |
<twig:Toaster>
| Prop | Type | Default |
|---|---|---|
position Corner of the screen the toast region is anchored to.
|
'top-left'|'top-center'|'top-right'|'bottom-left'|'bottom-center'|'bottom-right' |
'bottom-right' |
duration Auto-dismiss delay in milliseconds applied to toasts that do not set their own, where
0 keeps them until dismissed. |
int |
5000 |
limit Maximum number of toasts kept on screen; the ones beyond it fade out until a slot frees up.
|
int |
3 |
label Accessible name of the toast region; translate it when your interface is not in English.
|
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 Auto-dismiss delay in milliseconds for toasts that do not carry their own.
|
Number |
5000 |
data-toast-limit-value Maximum number of toasts kept on screen.
|
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. |