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 Stream

Variants 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>'
})

Table of contents

Get notified when new components come out