Inspired by Emil Kowalski's Sonner
Toast Notifications
Display temporary messages to users with toast notifications. Perfect for displaying success, error, warning, and info messages.
Installation
1. Add the Stimulus Controller
First, add the toast controller to your project:
import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="toast"
export default class extends Controller {
static targets = ["container"];
static values = {
position: { type: String, default: "top-center" },
layout: { type: String, default: "default" }, // "default" (stacked) or "expanded" (all visible)
gap: { type: Number, default: 14 }, // Gap between toasts in expanded mode
autoDismissDuration: { type: Number, default: 4000 },
limit: { type: Number, default: 3 }, // Maximum number of visible toasts
};
connect() {
this.toasts = [];
this.heights = []; // Track toast heights like Sonner
this.expanded = this.layoutValue === "expanded";
this.interacting = false;
// Store current position in a global variable that persists across interactions
if (!window.currentToastPosition) {
window.currentToastPosition = this.positionValue;
} else {
// Restore the position from the global variable
this.positionValue = window.currentToastPosition;
}
// Set initial position classes
this.updatePositionClasses();
// Make toast function globally available
if (!window.toast) {
window.toast = this.showToast.bind(this);
}
// Bind event handlers so they can be properly removed
this.boundHandleToastShow = this.handleToastShow.bind(this);
this.boundHandleLayoutChange = this.handleLayoutChange.bind(this);
this.boundBeforeCache = this.beforeCache.bind(this);
// Listen for toast events
window.addEventListener("toast-show", this.boundHandleToastShow);
window.addEventListener("set-toasts-layout", this.boundHandleLayoutChange);
document.addEventListener("turbo:before-cache", this.boundBeforeCache);
}
updatePositionClasses() {
const container = this.containerTarget;
// Remove all position classes
container.classList.remove(
"right-0",
"left-0",
"left-1/2",
"-translate-x-1/2",
"top-0",
"bottom-0",
"mt-4",
"mb-4",
"mr-4",
"ml-4",
"sm:mt-6",
"sm:mb-6",
"sm:mr-6",
"sm:ml-6"
);
// Add new position classes
const classes = this.positionClasses.split(" ");
container.classList.add(...classes);
}
disconnect() {
// Remove event listeners using the bound references
window.removeEventListener("toast-show", this.boundHandleToastShow);
window.removeEventListener("set-toasts-layout", this.boundHandleLayoutChange);
document.removeEventListener("turbo:before-cache", this.boundBeforeCache);
// Clear all auto-dismiss timers
if (this.autoDismissTimers) {
Object.values(this.autoDismissTimers).forEach((timer) => clearTimeout(timer));
this.autoDismissTimers = {};
}
// Clean up all toasts from the DOM
this.clearAllToasts();
}
showToast(message, options = {}) {
const detail = {
type: options.type || "default",
message: message,
description: options.description || "",
position: options.position || window.currentToastPosition || this.positionValue, // Use stored position
html: options.html || "",
action: options.action || null,
secondaryAction: options.secondaryAction || null,
};
window.dispatchEvent(new CustomEvent("toast-show", { detail }));
}
handleToastShow(event) {
event.stopPropagation();
// Update container position if a position is specified for this toast
if (event.detail.position) {
this.positionValue = event.detail.position;
window.currentToastPosition = event.detail.position; // Store globally
this.updatePositionClasses();
}
const toast = {
id: `toast-${Math.random().toString(16).slice(2)}`,
mounted: false,
removed: false,
message: event.detail.message,
description: event.detail.description,
type: event.detail.type,
html: event.detail.html,
action: event.detail.action,
secondaryAction: event.detail.secondaryAction,
};
// Add toast at the beginning of the array (newest first)
this.toasts.unshift(toast);
// Enforce toast limit synchronously to prevent race conditions
const activeToasts = this.toasts.filter((t) => !t.removed);
if (activeToasts.length > this.limitValue) {
const oldestActiveToast = activeToasts[activeToasts.length - 1];
if (oldestActiveToast && !oldestActiveToast.removed) {
this.removeToast(oldestActiveToast.id, true);
}
}
this.renderToast(toast);
}
handleLayoutChange(event) {
this.layoutValue = event.detail.layout;
this.expanded = this.layoutValue === "expanded";
this.updateAllToasts();
}
beforeCache() {
// Clear all toasts before the page is cached to prevent stale toasts on navigation
this.clearAllToasts();
// Reset position to default on navigation
window.currentToastPosition = this.element.dataset.toastPositionValue || "top-center";
}
clearAllToasts() {
// Remove all toast elements from DOM
const container = this.containerTarget;
if (container) {
while (container.firstChild) {
container.removeChild(container.firstChild);
}
}
// Clear arrays
this.toasts = [];
this.heights = [];
// Clear all timers
if (this.autoDismissTimers) {
Object.values(this.autoDismissTimers).forEach((timer) => clearTimeout(timer));
this.autoDismissTimers = {};
}
}
handleMouseEnter() {
if (this.layoutValue === "default") {
this.expanded = true;
this.updateAllToasts();
}
}
handleMouseLeave() {
if (this.layoutValue === "default" && !this.interacting) {
this.expanded = false;
this.updateAllToasts();
}
}
renderToast(toast) {
const container = this.containerTarget;
const li = this.createToastElement(toast);
container.insertBefore(li, container.firstChild);
// Measure height after a short delay to ensure rendering is complete
requestAnimationFrame(() => {
const toastEl = document.getElementById(toast.id);
if (toastEl) {
const height = toastEl.getBoundingClientRect().height;
// Add height to the beginning of heights array
this.heights.unshift({
toastId: toast.id,
height: height,
});
// Count only active (non-removed) toasts
const activeToasts = this.toasts.filter((t) => !t.removed);
// Trigger mount animation
requestAnimationFrame(() => {
toast.mounted = true;
toastEl.dataset.mounted = "true";
// Update all toast positions
this.updateAllToasts();
});
// Schedule auto-dismiss for visible toasts
const activeToastIndex = activeToasts.findIndex((t) => t.id === toast.id);
if (activeToastIndex < this.limitValue) {
this.scheduleAutoDismiss(toast.id);
}
}
});
}
scheduleAutoDismiss(toastId) {
if (!this.autoDismissTimers) {
this.autoDismissTimers = {};
}
if (this.autoDismissTimers[toastId]) {
clearTimeout(this.autoDismissTimers[toastId]);
}
this.autoDismissTimers[toastId] = setTimeout(() => {
this.removeToast(toastId);
delete this.autoDismissTimers[toastId];
}, this.autoDismissDurationValue);
}
createToastElement(toast) {
const li = document.createElement("li");
li.id = toast.id;
li.className = "toast-item sm:max-w-xs";
li.dataset.mounted = "false";
li.dataset.removed = "false";
li.dataset.position = this.positionValue;
li.dataset.expanded = this.expanded.toString();
li.dataset.visible = "true";
li.dataset.front = "false";
li.dataset.index = "0";
if (!toast.description) {
li.classList.add("toast-no-description");
}
const span = document.createElement("span");
span.className = `relative flex flex-col items-start shadow-xs w-full transition-all duration-200 bg-white border border-neutral-200 dark:border-neutral-700 dark:bg-neutral-800 rounded-lg sm:rounded-xl sm:max-w-xs group ${
toast.html ? "p-0" : "p-4"
}`;
span.style.transitionTimingFunction = "cubic-bezier(0.4, 0, 0.2, 1)";
if (toast.html) {
span.innerHTML = toast.html;
} else {
span.innerHTML = this.getToastHTML(toast);
}
// Add action button event listeners if not using custom HTML
if (!toast.html && (toast.action || toast.secondaryAction)) {
requestAnimationFrame(() => {
if (toast.action) {
const primaryBtn = span.querySelector('[data-action-type="primary"]');
if (primaryBtn) {
primaryBtn.addEventListener("click", (e) => {
e.stopPropagation();
toast.action.onClick();
this.removeToast(toast.id);
});
}
}
if (toast.secondaryAction) {
const secondaryBtn = span.querySelector('[data-action-type="secondary"]');
if (secondaryBtn) {
secondaryBtn.addEventListener("click", (e) => {
e.stopPropagation();
toast.secondaryAction.onClick();
this.removeToast(toast.id);
});
}
}
});
}
// Add close button
const closeBtn = document.createElement("span");
const hasActions = toast.action || toast.secondaryAction;
closeBtn.className = `absolute right-0 p-1.5 mr-2.5 text-neutral-400 duration-100 ease-in-out rounded-full cursor-pointer hover:bg-neutral-50 dark:hover:bg-neutral-700 hover:text-neutral-500 dark:hover:text-neutral-300 ${
!toast.description && !toast.html && !hasActions ? "top-1/2 -translate-y-1/2" : "top-0 mt-2.5"
}`;
closeBtn.innerHTML = `<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>`;
closeBtn.dataset.toastId = toast.id;
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.removeToast(toast.id);
});
span.appendChild(closeBtn);
li.appendChild(span);
return li;
}
getToastHTML(toast) {
const typeColors = {
success: "text-green-500 dark:text-green-400",
error: "text-red-500 dark:text-red-400",
info: "text-blue-500 dark:text-blue-400",
warning: "text-orange-400 dark:text-orange-300",
danger: "text-red-500 dark:text-red-400",
loading: "text-neutral-500 dark:text-neutral-400",
default: "text-neutral-800 dark:text-neutral-200",
};
const color = typeColors[toast.type] || typeColors.default;
const icons = {
success: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm3.843,5.708l-4.25,5.5c-.136,.176-.343,.283-.565,.291-.01,0-.019,0-.028,0-.212,0-.415-.09-.558-.248l-2.25-2.5c-.277-.308-.252-.782,.056-1.06,.309-.276,.781-.252,1.06,.056l1.648,1.832,3.701-4.789c.253-.328,.725-.388,1.052-.135,.328,.253,.388,.724,.135,1.052Z"></path></g></svg>`,
error: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9,1C4.589,1,1,4.589,1,9s3.589,8,8,8,8-3.589,8-8S13.411,1,9,1Zm3.28,10.22c.293,.293,.293,.768,0,1.061-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22l-2.22-2.22-2.22,2.22c-.146,.146-.338,.22-.53,.22s-.384-.073-.53-.22c-.293-.293-.293-.768,0-1.061l2.22-2.22-2.22-2.22c-.293-.293-.293-.768,0-1.061s.768-.293,1.061,0l2.22,2.22,2.22-2.22c.293-.293,.768-.293,1.061,0s.293,.768,0,1.061l-2.22,2.22,2.22,2.22Z"></path></g></svg>`,
info: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M9 1C4.5889 1 1 4.5889 1 9C1 13.4111 4.5889 17 9 17C13.4111 17 17 13.4111 17 9C17 4.5889 13.4111 1 9 1ZM9.75 12.75C9.75 13.1641 9.4141 13.5 9 13.5C8.5859 13.5 8.25 13.1641 8.25 12.75V9.5H7.75C7.3359 9.5 7 9.1641 7 8.75C7 8.3359 7.3359 8 7.75 8H8.5C9.1895 8 9.75 8.5605 9.75 9.25V12.75ZM9 6.75C8.448 6.75 8 6.301 8 5.75C8 5.199 8.448 4.75 9 4.75C9.552 4.75 10 5.199 10 5.75C10 6.301 9.552 6.75 9 6.75Z"></path></g></svg>`,
warning: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.4364 12.5151L11.0101 3.11316C10.5902 2.39096 9.83872 1.96045 8.99982 1.96045C8.16092 1.96045 7.40952 2.39106 6.98952 3.11316C6.98902 3.11366 6.98902 3.11473 6.98852 3.11523L1.56272 12.5156C1.14332 13.2436 1.14332 14.1128 1.56372 14.8398C1.98362 15.5664 2.73562 16 3.57492 16H14.4245C15.2639 16 16.0158 15.5664 16.4357 14.8398C16.8561 14.1127 16.8563 13.2436 16.4364 12.5151ZM8.24992 6.75C8.24992 6.3359 8.58582 6 8.99992 6C9.41402 6 9.74992 6.3359 9.74992 6.75V9.75C9.74992 10.1641 9.41402 10.5 8.99992 10.5C8.58582 10.5 8.24992 10.1641 8.24992 9.75V6.75ZM8.99992 13.5C8.44792 13.5 7.99992 13.0498 7.99992 12.5C7.99992 11.9502 8.44792 11.5 8.99992 11.5C9.55192 11.5 9.99992 11.9502 9.99992 12.5C9.99992 13.0498 9.55192 13.5 8.99992 13.5Z"></path></g></svg>`,
danger: `<svg class="size-4.5 mr-1.5 -ml-1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M16.4364 12.5151L11.0101 3.11316C10.5902 2.39096 9.83872 1.96045 8.99982 1.96045C8.16092 1.96045 7.40952 2.39106 6.98952 3.11316C6.98902 3.11366 6.98902 3.11473 6.98852 3.11523L1.56272 12.5156C1.14332 13.2436 1.14332 14.1128 1.56372 14.8398C1.98362 15.5664 2.73562 16 3.57492 16H14.4245C15.2639 16 16.0158 15.5664 16.4357 14.8398C16.8561 14.1127 16.8563 13.2436 16.4364 12.5151ZM8.24992 6.75C8.24992 6.3359 8.58582 6 8.99992 6C9.41402 6 9.74992 6.3359 9.74992 6.75V9.75C9.74992 10.1641 9.41402 10.5 8.99992 10.5C8.58582 10.5 8.24992 10.1641 8.24992 9.75V6.75ZM8.99992 13.5C8.44792 13.5 7.99992 13.0498 7.99992 12.5C7.99992 11.9502 8.44792 11.5 8.99992 11.5C9.55192 11.5 9.99992 11.9502 9.99992 12.5C9.99992 13.0498 9.55192 13.5 8.99992 13.5Z"></path></g></svg>`,
loading: `<svg class="size-4.5 mr-1.5 -ml-1 animate-spin" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="m9,17c-4.4111,0-8-3.5889-8-8S4.5889,1,9,1s8,3.5889,8,8-3.5889,8-8,8Zm0-14.5c-3.584,0-6.5,2.916-6.5,6.5s2.916,6.5,6.5,6.5,6.5-2.916,6.5-6.5-2.916-6.5-6.5-6.5Z" opacity=".4" stroke-width="0"></path><path d="m16.25,9.75c-.4141,0-.75-.3359-.75-.75,0-3.584-2.916-6.5-6.5-6.5-.4141,0-.75-.3359-.75-.75s.3359-.75.75-.75c4.4111,0,8,3.5889,8,8,0,.4141-.3359.75-.75.75Z" stroke-width="0"></path></g></svg>
`,
};
const icon = icons[toast.type] || "";
// Action buttons HTML
const hasActions = toast.action || toast.secondaryAction;
const actionsHTML = hasActions
? `<div></div>
<div class="flex justify-end items-center gap-2 mt-0.5">
${
toast.secondaryAction
? `<button data-action-type="secondary" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-2 py-1.5 text-xs font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200">${toast.secondaryAction.label}</button>`
: ""
}
${
toast.action
? `<button data-action-type="primary" class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-400/30 bg-neutral-800 px-2 py-1.5 text-xs font-medium whitespace-nowrap text-white shadow-sm transition-all duration-100 ease-in-out select-none hover:bg-neutral-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-white dark:text-neutral-800 dark:hover:bg-neutral-100 dark:focus-visible:outline-neutral-200">${toast.action.label}</button>`
: ""
}
</div>`
: "";
return `
<div class="relative w-full">
<div class="grid grid-cols-[auto_1fr] gap-y-1.5 items-start">
<div class="flex items-center h-full ${color}">
${icon}
</div>
<p class="text-[13px] font-medium text-neutral-800 dark:text-neutral-200 pr-6">
${toast.message}
</p>
${
toast.description
? `<div></div>
<div class="text-xs text-neutral-600 dark:text-neutral-400">
${toast.description}
</div>`
: ""
}
${actionsHTML}
</div>
</div>
`;
}
removeToast(id, isOverflow = false) {
const toast = this.toasts.find((t) => t.id === id);
if (!toast || toast.removed) return;
const toastEl = document.getElementById(id);
if (!toastEl) return;
// Mark as removed
toast.removed = true;
toastEl.dataset.removed = "true";
// Mark if this is an overflow removal
if (isOverflow) {
toastEl.dataset.overflow = "true";
}
// Clear auto-dismiss timer
if (this.autoDismissTimers && this.autoDismissTimers[id]) {
clearTimeout(this.autoDismissTimers[id]);
delete this.autoDismissTimers[id];
}
// Wait for exit animation to complete
setTimeout(() => {
// Remove from arrays
this.toasts = this.toasts.filter((t) => t.id !== id);
this.heights = this.heights.filter((h) => h.toastId !== id);
// Remove from DOM
if (toastEl.parentNode) {
toastEl.parentNode.removeChild(toastEl);
}
// Update remaining toasts
this.updateAllToasts();
// Schedule auto-dismiss for newly visible toast
if (this.toasts.length >= this.limitValue) {
const newlyVisibleToast = this.toasts[this.limitValue - 1];
if (newlyVisibleToast && !this.autoDismissTimers[newlyVisibleToast.id]) {
this.scheduleAutoDismiss(newlyVisibleToast.id);
}
}
}, 400); // Match the exit animation duration (400ms)
}
updateAllToasts() {
requestAnimationFrame(() => {
const visibleToasts = this.limitValue;
// Calculate visual index (excluding removed toasts)
let visualIndex = 0;
this.toasts.forEach((toast, index) => {
const toastEl = document.getElementById(toast.id);
if (!toastEl) return;
// Handle overflow toasts (removed due to limit) separately
if (toast.removed && toastEl.dataset.overflow === "true") {
// Position as if it's the last visible toast
toastEl.dataset.index = String(this.limitValue - 1);
toastEl.dataset.visible = "true";
toastEl.dataset.expanded = this.expanded.toString();
toastEl.dataset.position = this.positionValue;
// Set lowest z-index so it appears behind all active toasts
toastEl.style.setProperty("--toast-z-index", 0);
toastEl.style.setProperty("--toast-index", this.limitValue - 1);
return;
}
// Skip other removed toasts
if (toast.removed) return;
const isVisible = visualIndex < visibleToasts;
const isFront = visualIndex === 0;
// Calculate offset (cumulative height of non-removed toasts before this one)
let offset = 0;
for (let i = 0; i < index; i++) {
if (this.toasts[i].removed) continue;
const heightInfo = this.heights.find((h) => h.toastId === this.toasts[i].id);
if (heightInfo) {
offset += heightInfo.height + this.gapValue;
}
}
// Update data attributes - CSS will handle styling
toastEl.dataset.expanded = this.expanded.toString();
toastEl.dataset.visible = isVisible.toString();
toastEl.dataset.front = isFront.toString();
toastEl.dataset.index = visualIndex.toString();
toastEl.dataset.position = this.positionValue;
// Set CSS custom properties for dynamic values
toastEl.style.setProperty("--toast-z-index", 100 - visualIndex);
toastEl.style.setProperty("--toast-offset", `${offset}px`);
toastEl.style.setProperty("--toast-index", visualIndex);
// Set the initial height of this specific toast
const heightInfo = this.heights.find((h) => h.toastId === toast.id);
if (heightInfo) {
toastEl.style.setProperty("--initial-height", `${heightInfo.height}px`);
}
// In stacked mode, set all toasts to front toast height for uniform appearance
if (!this.expanded) {
const frontHeight = this.heights[0]?.height || 0;
toastEl.style.setProperty("--front-toast-height", `${frontHeight}px`);
} else {
toastEl.style.removeProperty("--front-toast-height");
}
// Increment visual index for next non-removed toast
visualIndex++;
});
// Update container height immediately and after transitions complete
this.updateContainerHeight();
setTimeout(() => this.updateContainerHeight(), 400);
});
}
updateContainerHeight() {
// Count non-removed toasts
const activeToasts = this.toasts.filter((t) => !t.removed);
if (activeToasts.length === 0) {
this.containerTarget.style.height = "0px";
return;
}
if (this.expanded) {
// In expanded mode, calculate total height of all visible non-removed toasts
let totalHeight = 0;
const visibleToasts = Math.min(activeToasts.length, this.limitValue);
for (let i = 0; i < visibleToasts; i++) {
const heightInfo = this.heights.find((h) => h.toastId === activeToasts[i].id);
if (heightInfo) {
totalHeight += heightInfo.height;
if (i < visibleToasts - 1) {
totalHeight += this.gapValue;
}
}
}
this.containerTarget.style.height = totalHeight + "px";
} else {
// In stacked mode, calculate based on front non-removed toast + peek amounts
const frontToast = activeToasts[0];
const frontHeight = frontToast ? this.heights.find((h) => h.toastId === frontToast.id)?.height || 0 : 0;
const peekAmount = 24;
const visibleCount = Math.min(activeToasts.length, this.limitValue);
const totalHeight = frontHeight + peekAmount * (visibleCount - 1);
this.containerTarget.style.height = totalHeight + "px";
}
}
get positionClasses() {
const positions = {
"top-right": "right-0 top-0 mt-4 mr-4 sm:mt-6 sm:mr-6",
"top-left": "left-0 top-0 mt-4 ml-4 sm:mt-6 sm:ml-6",
"top-center": "left-1/2 -translate-x-1/2 top-0 mt-4 sm:mt-6",
"bottom-right": "right-0 bottom-0 mb-4 mr-4 sm:mr-6 sm:mb-6",
"bottom-left": "left-0 bottom-0 mb-4 ml-4 sm:ml-6 sm:mb-6",
"bottom-center": "left-1/2 -translate-x-1/2 bottom-0 mb-4 sm:mb-6",
};
return positions[this.positionValue] || positions["top-center"];
}
}
2. Configure the Toast Container & Flash Messages
Create a new shared partial for the toast container (app/views/shared/_toast_container.html.erb
):
<div data-controller="toast"
id="toast_triggers"
data-toast-position-value="top-center"
data-toast-layout-value="default"> <%# Change to "expanded" for expanded mode %>
<ul
data-toast-target="container"
data-action="mouseenter->toast#handleMouseEnter mouseleave->toast#handleMouseLeave"
class="fixed block w-full group z-[99] max-w-[300px] sm:max-w-xs left-1/2 -translate-x-1/2 top-0 overflow-visible"
style="transition: height 300ms ease;"
>
<%# Toast notifications will be dynamically inserted here %>
<%# Position classes are managed by the Stimulus controller %>
</ul>
</div>
Configure flash messages so they show as toasts:
<%# Automatically trigger toast notifications from Rails flash messages %>
<% if flash[:toast].present? %>
<% toast_data = flash[:toast].is_a?(Hash) ? flash[:toast].with_indifferent_access : {} %>
<script>
document.addEventListener('turbo:load', function() {
toast('<%= j toast_data[:message] %>', {
type: '<%= toast_data[:type] || "default" %>',
description: '<%= j toast_data[:description].to_s %>'
});
}, { once: true });
</script>
<% end %>
<% if notice.present? %>
<script>
document.addEventListener('turbo:load', function() {
toast('<%= j notice %>', {
type: 'success'
});
}, { once: true });
</script>
<% end %>
<% if alert.present? %>
<script>
document.addEventListener('turbo:load', function() {
toast('<%= j alert %>', {
type: 'error'
});
}, { once: true });
</script>
<% end %>
Add the container partial & flash partial to your app/views/layouts/application.html.erb
file before the closing </body>
tag:
<%# Add the toast container to the application layout %>
<%# ... Your Application Layout ... %>
<body>
<%= render "shared/header" %>
<main>
<%= render "shared/toast_container" %> <%# 👈 Add the toast container here %>
<%= render "shared/flash" %> <%# 👈 Add the flash partial here %>
<%= yield %>
<%= render "shared/footer" %>
</main>
</body>
3. Custom CSS
Here are the custom CSS classes that we used on Rails Blocks to style the date picker components. You can copy and paste these into your own CSS file to style & personalize your date pickers.
/* Toast Notifications */
.toast-item {
@apply absolute w-full left-0 select-none;
z-index: var(--toast-z-index, 100);
/* Position based on data attributes */
top: var(--toast-top, auto);
bottom: var(--toast-bottom, auto);
transform: var(--toast-transform, translateY(0));
scale: var(--toast-scale, 100%);
opacity: var(--toast-opacity, 1);
/* Separate transitions for better control - like Sonner */
transition: transform 400ms ease, opacity 400ms ease, scale 400ms ease, top 400ms ease, bottom 400ms ease,
height 200ms ease;
}
/* Initial hidden state for enter animation */
.toast-item[data-mounted="false"] {
opacity: 0;
}
.toast-item[data-mounted="false"][data-position*="bottom"] {
transform: translateY(100%);
}
.toast-item[data-mounted="false"][data-position*="top"] {
transform: translateY(-100%);
}
/* Removed state for exit animation */
.toast-item[data-removed="true"] {
opacity: 0;
scale: 95%;
pointer-events: none;
}
/* In stacked mode, removed toasts should slide away */
.toast-item[data-removed="true"][data-expanded="false"][data-position*="bottom"] {
transform: translateY(calc(var(--toast-index) * 14px + 5%));
}
.toast-item[data-removed="true"][data-expanded="false"][data-position*="top"] {
transform: translateY(calc(-1 * (var(--toast-index) * 14px + 5%)));
}
/* Overflow toasts (removed due to limit) slide in opposite direction */
.toast-item[data-removed="true"][data-overflow="true"][data-expanded="false"][data-position*="bottom"] {
transform: translateY(calc(-1 * (var(--toast-index) * 14px + 25%)));
scale: 78%;
}
.toast-item[data-removed="true"][data-overflow="true"][data-expanded="false"][data-position*="top"] {
transform: translateY(calc(var(--toast-index) * 14px + 25%));
scale: 78%;
}
/* Pointer events based on visibility */
.toast-item[data-visible="false"] {
pointer-events: none;
}
/* Expanded mode styles */
.toast-item[data-expanded="true"] {
--toast-scale: 100%;
height: var(--initial-height);
}
.toast-item[data-expanded="true"][data-position*="bottom"] {
--toast-top: auto;
--toast-bottom: var(--toast-offset, 0px);
--toast-transform: translateY(0);
}
.toast-item[data-expanded="true"][data-position*="top"] {
--toast-top: var(--toast-offset, 0px);
--toast-bottom: auto;
--toast-transform: translateY(0);
}
/* Stacked mode styles */
.toast-item[data-expanded="false"][data-front="true"] {
--toast-scale: 100%;
--toast-opacity: 1;
height: var(--initial-height);
}
.toast-item[data-expanded="false"][data-front="true"][data-position*="bottom"] {
--toast-top: auto;
--toast-bottom: 0px;
--toast-transform: translateY(0);
}
.toast-item[data-expanded="false"][data-front="true"][data-position*="top"] {
--toast-top: 0px;
--toast-bottom: auto;
--toast-transform: translateY(0);
}
/* Non-front toasts in stack - Sonner approach */
.toast-item[data-expanded="false"]:not([data-front="true"]) {
height: var(--front-toast-height);
overflow: hidden;
--toast-scale: calc(100% - (var(--toast-index) * 6%));
display: flex;
flex-direction: column;
}
.toast-item[data-expanded="false"]:not([data-front="true"])[data-position*="bottom"] {
--toast-top: auto;
--toast-bottom: 0px;
--toast-transform: translateY(calc(-1 * var(--toast-index) * 14px));
justify-content: flex-start; /* Content at top, bottom gets cut */
}
.toast-item[data-expanded="false"]:not([data-front="true"])[data-position*="top"] {
--toast-top: 0px;
--toast-bottom: auto;
--toast-transform: translateY(calc(var(--toast-index) * 14px));
justify-content: flex-end; /* Content at bottom, top gets cut */
}
/* Hidden toasts (beyond 3rd) */
.toast-item[data-expanded="false"][data-visible="false"] {
--toast-opacity: 0;
--toast-scale: 82%;
}
.toast-item[data-expanded="false"][data-visible="false"][data-position*="bottom"] {
--toast-top: auto;
--toast-bottom: -200px;
--toast-transform: translateY(0);
}
.toast-item[data-expanded="false"][data-visible="false"][data-position*="top"] {
--toast-top: -200px;
--toast-bottom: auto;
--toast-transform: translateY(0);
}
Examples
Basic Toast
A simple toast notification that displays a message to the user.
<button
type="button"
onclick="toast('Default Toast Notification')"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Show Toast
</button>
Toast Types
Different toast types for various notification scenarios: default, success, info, warning, and danger.
<div class="flex flex-wrap gap-3">
<button
type="button"
onclick="toast('Default Notification', { type: 'default' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Default
</button>
<button
type="button"
onclick="toast('Success Notification', { type: 'success' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Success
</button>
<button
type="button"
onclick="toast('Error Notification', { type: 'error' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Error
</button>
<button
type="button"
onclick="toast('Info Notification', { type: 'info' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Info
</button>
<button
type="button"
onclick="toast('Warning Notification', { type: 'warning' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Warning
</button>
<button
type="button"
onclick="toast('Danger Notification', { type: 'danger' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Danger
</button>
<button
type="button"
onclick="toast('Loading...', { type: 'loading' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Loading
</button>
</div>
Toast with Description
Add additional context with a description below the main message.
<button
type="button"
onclick="toast('Toast Notification', { description: 'This is an example toast notification with a longer description that provides additional context.' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Show with Description
</button>
Toast Positions
Control where toasts appear on the screen with six different positions.
<div class="grid grid-cols-2 md:grid-cols-3 gap-3 max-w-2xl">
<button
type="button"
onclick="toast('Top Left', { position: 'top-left' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Top Left
</button>
<button
type="button"
onclick="toast('Top Center', { position: 'top-center' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Top Center
</button>
<button
type="button"
onclick="toast('Top Right', { position: 'top-right' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Top Right
</button>
<button
type="button"
onclick="toast('Bottom Left', { position: 'bottom-left' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Bottom Left
</button>
<button
type="button"
onclick="toast('Bottom Center', { position: 'bottom-center' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Bottom Center
</button>
<button
type="button"
onclick="toast('Bottom Right', { position: 'bottom-right' })"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Bottom Right
</button>
</div>
Toasts with Action Buttons
Create toasts with action buttons.
<div class="flex flex-col lg:flex-row flex-wrap items-center gap-3">
<%# Primary Action Only %>
<button
type="button"
onclick="toast('Event has been created', {
description: 'Sunday, December 03, 2023 at 9:00 AM',
action: {
label: 'Undo',
onClick: () => console.log('Undo clicked')
}
})"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
With Primary Action
</button>
<%# Both Actions %>
<button
type="button"
onclick="toast('Delete file?', {
description: 'This action cannot be undone.',
action: {
label: 'Delete',
onClick: () => console.log('Delete confirmed')
},
secondaryAction: {
label: 'Cancel',
onClick: () => console.log('Cancelled')
}
})"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
With Both Actions
</button>
<%# Action without description %>
<button
type="button"
onclick="toast('File uploaded successfully', {
type: 'success',
action: {
label: 'View',
onClick: () => console.log('View file')
}
})"
class="flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200"
>
Without Description
</button>
</div>
Custom HTML Toast
Create completely custom toasts with your own HTML structure.
<button
type="button"
onclick="toast('', { html: `
<div class='relative flex items-start justify-center p-4'>
<div class='w-10 h-10 mr-3 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0'>
<svg class='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z'></path>
</svg>
</div>
<div class='flex flex-col'>
<p class='text-sm font-medium text-neutral-800 dark:text-neutral-200'>New Friend Request</p>
<p class='mt-1 text-xs leading-none text-neutral-600 dark:text-neutral-400'>Friend request from John Doe.</p>
<div class='flex mt-3 gap-2'>
<button type='button' class='inline-flex items-center px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 rounded-lg shadow-sm hover:bg-blue-500 dark:bg-blue-500 dark:hover:bg-blue-400'>Accept</button>
<button type='button' class='inline-flex items-center px-3 py-1.5 text-xs font-semibold text-neutral-700 bg-white dark:bg-neutral-700 dark:text-neutral-200 rounded-lg shadow-sm border border-neutral-300 dark:border-neutral-600 hover:bg-neutral-50 dark:hover:bg-neutral-600'>Decline</button>
</div>
</div>
</div>
` })"
class="inline-flex flex-shrink-0 items-center justify-center rounded-lg border border-neutral-200 px-3 py-2 text-sm font-medium text-neutral-800 transition-colors hover:bg-neutral-50 focus:bg-white focus:outline-none active:bg-white dark:border-neutral-700 dark:text-neutral-200 dark:hover:bg-neutral-800 dark:focus:bg-neutral-900 dark:active:bg-neutral-900"
>
Custom HTML Toast
</button>
Server-Triggered Toasts
Trigger toast notifications from your Rails controllers and pass server-side data. This example shows how to check if the current minute is odd or even.
Variants that does not show a loading toast
Load Server Data via Turbo StreamVariants that first shows a loading toast
Show Loading Toast + Turbo Stream Toast<div class="space-y-4 flex flex-col items-center">
<p class="text-sm text-neutral-600 dark:text-neutral-400">Variants that does not show a loading toast</p>
<%# Approach 1: Using Flash Messages %>
<%= button_to trigger_flash_toast_path,
method: :post,
class: "flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" do %>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.6449 7.0522C15.474 6.7114 15.1317 6.5 14.7504 6.5H10.2948L10.559 2.0312C10.5844 1.6025 10.3305 1.2148 9.9272 1.0668C9.5244 0.920302 9.0791 1.0507 8.8222 1.3949L2.4492 9.9008C2.2207 10.206 2.185 10.6069 2.3554 10.9477C2.5258 11.2885 2.8686 11.4999 3.2494 11.4999H7.705L7.4408 15.9687C7.4154 16.3974 7.6693 16.7851 8.0726 16.9331C8.1825 16.9731 8.2957 16.9927 8.4076 16.9927C8.705 16.9927 8.9906 16.855 9.1776 16.605L15.5511 8.0991C15.7791 7.7944 15.8153 7.393 15.6449 7.0522Z"></path></g></svg>
Trigger Flash Toast (Will Refresh)
<% end %>
<%# Approach 2: Using Turbo Stream to call toast() directly %>
<%= link_to server_toast_data_path(format: :turbo_stream),
data: { turbo_stream: true },
class: "flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" do %>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.6449 7.0522C15.474 6.7114 15.1317 6.5 14.7504 6.5H10.2948L10.559 2.0312C10.5844 1.6025 10.3305 1.2148 9.9272 1.0668C9.5244 0.920302 9.0791 1.0507 8.8222 1.3949L2.4492 9.9008C2.2207 10.206 2.185 10.6069 2.3554 10.9477C2.5258 11.2885 2.8686 11.4999 3.2494 11.4999H7.705L7.4408 15.9687C7.4154 16.3974 7.6693 16.7851 8.0726 16.9331C8.1825 16.9731 8.2957 16.9927 8.4076 16.9927C8.705 16.9927 8.9906 16.855 9.1776 16.605L15.5511 8.0991C15.7791 7.7944 15.8153 7.393 15.6449 7.0522Z"></path></g></svg>
Load Server Data via Turbo Stream
<% end %>
</div>
<div class="space-y-4 flex flex-col items-center mt-8">
<p class="text-sm text-neutral-600 dark:text-neutral-400">Variants that first shows a loading toast</p>
<%# Approach 1: Using Flash Messages (With Delay) %>
<%= button_to trigger_flash_toast_path,
method: :post,
onclick: "event.preventDefault(); this.disabled = true; toast('Processing your request...', { type: 'loading' }); setTimeout(() => { this.closest('form').submit(); }, 3000);", # Don't forget to remove the delay when you're done testing
class: "flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" do %>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.6449 7.0522C15.474 6.7114 15.1317 6.5 14.7504 6.5H10.2948L10.559 2.0312C10.5844 1.6025 10.3305 1.2148 9.9272 1.0668C9.5244 0.920302 9.0791 1.0507 8.8222 1.3949L2.4492 9.9008C2.2207 10.206 2.185 10.6069 2.3554 10.9477C2.5258 11.2885 2.8686 11.4999 3.2494 11.4999H7.705L7.4408 15.9687C7.4154 16.3974 7.6693 16.7851 8.0726 16.9331C8.1825 16.9731 8.2957 16.9927 8.4076 16.9927C8.705 16.9927 8.9906 16.855 9.1776 16.605L15.5511 8.0991C15.7791 7.7944 15.8153 7.393 15.6449 7.0522Z"></path></g></svg>
Show Loading Toast + Flash Toast (Will Refresh)
<% end %>
<%# Approach 2: Using Turbo Stream with Loading State %>
<%= link_to server_toast_data_path(format: :turbo_stream),
data: { turbo_stream: true },
onclick: "event.preventDefault(); toast('Loading server data...', { type: 'loading' }); const url = this.href; setTimeout(() => { fetch(url, { headers: { 'Accept': 'text/vnd.turbo-stream.html' } }).then(response => response.text()).then(html => { Turbo.renderStreamMessage(html); }); }, 3000);", # Don't forget to remove the delay when you're done testing
class: "flex items-center justify-center gap-1.5 rounded-lg border border-neutral-200 bg-white/90 px-3.5 py-2 text-sm font-medium whitespace-nowrap text-neutral-800 shadow-xs transition-all duration-100 ease-in-out select-none hover:bg-neutral-50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-600 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-700 dark:bg-neutral-800/50 dark:text-neutral-50 dark:hover:bg-neutral-700/50 dark:focus-visible:outline-neutral-200" do %>
<svg xmlns="http://www.w3.org/2000/svg" class="size-3.5 sm:size-4" width="18" height="18" viewBox="0 0 18 18"><g fill="currentColor"><path d="M15.6449 7.0522C15.474 6.7114 15.1317 6.5 14.7504 6.5H10.2948L10.559 2.0312C10.5844 1.6025 10.3305 1.2148 9.9272 1.0668C9.5244 0.920302 9.0791 1.0507 8.8222 1.3949L2.4492 9.9008C2.2207 10.206 2.185 10.6069 2.3554 10.9477C2.5258 11.2885 2.8686 11.4999 3.2494 11.4999H7.705L7.4408 15.9687C7.4154 16.3974 7.6693 16.7851 8.0726 16.9331C8.1825 16.9731 8.2957 16.9927 8.4076 16.9927C8.705 16.9927 8.9906 16.855 9.1776 16.605L15.5511 8.0991C15.7791 7.7944 15.8153 7.393 15.6449 7.0522Z"></path></g></svg>
Show Loading Toast + Turbo Stream Toast
<% end %>
</div>
# config/routes.rb
# Add these routes for server-triggered toasts
Rails.application.routes.draw do
# ... your other routes
# Toast example routes
post "trigger_flash_toast", to: "toasts#trigger_flash_toast", as: :trigger_flash_toast
get "server_toast_data", to: "toasts#server_toast_data", as: :server_toast_data
end
# app/controllers/toasts_controller.rb
# Example controller for server-triggered toast notifications
class ToastsController < ApplicationController
# Approach 1: Flash Message (for redirects)
def trigger_flash_toast
current_minute = Time.current.min
is_odd = current_minute.odd?
flash[:toast] = {
message: is_odd ? "It's an odd minute!" : "It's an even minute!",
description: "Current minute: #{current_minute}",
type: "info"
}
redirect_to pages_toast_path
end
# Approach 2: Turbo Stream with direct toast() call
def server_toast_data
current_minute = Time.current.min
is_odd = current_minute.odd?
message = is_odd ? "It's an odd minute!" : "It's an even minute!"
description = "Minute: #{current_minute}"
respond_to do |format|
format.turbo_stream do
render turbo_stream: turbo_stream.append("toast_triggers", <<~HTML)
<script>
toast(#{message.to_json}, {
type: "info",
description: #{description.to_json}
});
</script>
HTML
end
end
end
end
Configuration
The toast component is powered by a Stimulus controller that provides automatic positioning, stacking behavior, and flexible configuration options.
Values
Prop | Description | Type | Default |
---|---|---|---|
position
|
Position on screen where toasts appear |
String
|
"top-center"
|
layout
|
Layout mode: "default" (stacked with hover to expand) or "expanded" (all visible) |
String
|
"default"
|
gap
|
Gap between toasts in pixels (expanded mode) |
Number
|
14
|
autoDismissDuration
|
Auto-dismiss duration in milliseconds |
Number
|
4000
|
limit
|
Maximum number of visible toasts at once |
Number
|
3
|
Options
The toast()
function accepts the following options:
Option | Description | Type | Default |
---|---|---|---|
type
|
Toast type: 'default', 'success', 'info', 'warning', 'danger' |
String
|
'default'
|
description
|
Additional description text below the message |
String
|
''
|
position
|
Position: 'top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right' |
String
|
'top-center'
|
html
|
Custom HTML content (overrides message and type) |
String
|
''
|
action
|
Primary action button with label and onClick callback: { label: 'Undo', onClick: () => {} } |
Object
|
null
|
secondaryAction
|
Secondary action button with label and onClick callback: { label: 'Cancel', onClick: () => {} } |
Object
|
null
|
Targets
Target | Description | Required |
---|---|---|
container
|
The main container where toast notifications are rendered | Required |
Actions
Action | Description | Usage |
---|---|---|
handleMouseEnter
|
Expands stacked toasts on hover (default layout only) |
data-action="mouseenter->toast#handleMouseEnter"
|
handleMouseLeave
|
Collapses toasts when mouse leaves (default layout only) |
data-action="mouseleave->toast#handleMouseLeave"
|
Events
toast-show
Dispatched when showing a new toast. The event detail contains the toast configuration (type, message, description, position, html, action, secondaryAction).
window.dispatchEvent(new CustomEvent('toast-show', {
detail: {
type: 'success',
message: 'Changes saved!',
description: 'Your profile has been updated'
}
}));
set-toasts-layout
Dispatched to change the layout mode dynamically. The event detail should include the layout type ('default' or 'expanded').
window.dispatchEvent(new CustomEvent('set-toasts-layout', {
detail: { layout: 'expanded' }
}));
Accessibility Features
- Automatic Dismissal: Toasts automatically dismiss after the configured duration
- Manual Close: Users can manually close any toast with the close button
- Visual Feedback: Clear visual indicators for different toast types (success, error, warning, info)
- Non-Intrusive: Toasts appear at screen edges and don't block main content
Advanced Features
- Smart Stacking: In default mode, toasts stack neatly with a peek preview; hover to expand all
- Position Control: Six different positions available (top/bottom × left/center/right)
- Custom HTML: Full control over toast content with custom HTML
- Action Buttons: Add primary and secondary action buttons with callbacks
- Server Integration: Trigger toasts from Rails controllers using Turbo Streams
-
Global API: Simple JavaScript API accessible via
toast()
function - Auto-trigger on Load: Display toasts automatically when page loads using data attributes
Usage
Once installed, you can trigger toasts from anywhere in your application using the global toast()
function:
// Basic usage
toast('Your changes have been saved')
// With options
toast('Success!', {
type: 'success',
description: 'Your profile has been updated successfully.',
position: 'top-right'
})
// Custom HTML
toast('', {
html: '<div class="p-4"><h3>Custom Toast</h3></div>'
})