Fluxon.Components.Toast (Fluxon v3.0.0)

Renders a toaster container that surfaces transient notifications driven entirely from the server. Toasts stack, auto-dismiss, and survive WebSocket reconnects without losing state.

Toasts are delivered through two complementary server APIs that feed the same on-page toaster, so the choice between them is about transport, not visuals.

Setup

Place the toaster exactly once in your app layout, typically Layouts.app/1 (or whichever function component or template wraps {@inner_content}), and pass @flash. Keep it out of root.html.heex: the LiveView hook can only attach to elements inside the LiveView mount, and live routes need the hook for send_toast/3 and callback actions.

<.toaster flash={@flash} />

On pages with no LiveView at all (controller-rendered views), the toaster runs in a standalone mode: a small bootstrap mounts the same JavaScript component without a socket, so flash toasts queued with Fluxon.put_toast/3 render with full behavior, including timers, the close button, swipe, and link actions. On live routes the same bootstrap shows flash toasts at page load, before the socket connects; when the LiveView hook mounts it takes over the visible toasts in place, resuming their dismiss timers.

Visible toasts do not survive push_navigate/2

The toaster mounts inside the LiveView, so a push_navigate/2 unmounts both the LiveView and the toaster element on screen. Any toasts currently visible at the moment of navigation are dropped, including those queued via send_toast/3. Use put_toast/3 for notifications that must outlive a redirect: the flash payload rides through the navigation and the destination page re-renders the toast.

Remove <.flash_group>

If your layout still renders Phoenix's stock <.flash_group flash={@flash} />, remove it. The toaster replaces it. Keeping both surfaces the internal Fluxon flash key as raw stringified text in the flash group.

Choosing a transport

Fluxon.put_toast/3 rides on Phoenix flash so notifications survive navigation across controllers, redirects, and push_navigate/2. Fluxon.send_toast/3 pushes events directly into the current LiveView for rich HEEx content and callback actions.

FunctionSurvives navigationWorks from controllersHEEx contentCallback actions
Fluxon.put_toast/3yesyesnono
Fluxon.send_toast/3nonoyesyes
Fluxon.send_toast_loading/3nonoyesyes
Fluxon.resolve_toast/4nonoyesyes

Use put_toast/3 whenever the response navigates (controller redirects, push_navigate/2, live_redirect) or comes from a dead view. Use send_toast/3 when the LiveView stays mounted and you want richer behavior.

Usage

From a LiveView

# Survives push_navigate. The same call works in any handler.
def handle_event("save", _, socket) do
  {:noreply,
   socket
   |> Fluxon.put_toast("Changes saved!", color: :success)
   |> push_navigate(to: ~p"/dashboard")}
end

# Stays in the current LiveView. Supports rich callback actions.
def handle_event("delete", %{"id" => id}, socket) do
  {:noreply,
   Fluxon.send_toast(socket, "Item deleted",
     color: :info,
     action: %{label: "Undo", type: :event, event: "undo", value: %{id: id}})}
end

From a Controller

def create(conn, params) do
  # ... save the record ...
  conn
  |> Fluxon.put_toast("Welcome aboard!", color: :success)
  |> redirect(to: ~p"/dashboard")
end

Colors

The available colors map to semantic intent. When no color is specified the toast renders in a neutral default style.

Fluxon.put_toast(socket, "Heads up",          color: :info)
Fluxon.put_toast(socket, "Saved",             color: :success)
Fluxon.put_toast(socket, "Approaching limit", color: :warning)
Fluxon.put_toast(socket, "Failed to save",    color: :danger)
Fluxon.put_toast(socket, "Just a note")  # neutral default
ColorUse case
:infoInformational nudges, e.g. "New version available".
:successPositive confirmations such as "Saved" or "Sent".
:warningNon-blocking caution like "Approaching quota".
:dangerFailures the user should notice, such as "Failed to save".
(none)Neutral output such as "Copied to clipboard".

Each color also drives an icon shown next to the title. Pass icon: false to suppress it on a per-toast basis.

Dismissal

A toast can go away through independent triggers, each controlled separately:

  • :dismiss_after (milliseconds): timer-driven dismissal. Defaults to 4000. Pass nil for persistent toasts.
  • :dismissible: whether the user can dismiss (close button, swipe, Escape). Defaults to true.
  • Per-action :dismiss: whether clicking an action dismisses the toast. Defaults to true per action.
# Default: auto-dismiss after 4 seconds.
Fluxon.put_toast(socket, "Saved")

# Custom auto-dismiss timer.
Fluxon.put_toast(socket, "Saved", dismiss_after: 8_000)

# Persistent. Stays until the user dismisses it (or you call dismiss_toast/2).
Fluxon.put_toast(socket, "Important notice", dismiss_after: nil)

# Cannot be dismissed by the user. Typical for loading toasts.
Fluxon.send_toast(socket, "Working...", dismiss_after: nil, dismissible: false)

Hovering anywhere on the toaster pauses every running auto-dismiss timer and freezes the progress bar; leaving resumes them. Switching away from the tab pauses the timers too, so a toast is not silently consumed while the page is in the background, and returning resumes them. On touch devices the user can swipe a toast toward its anchored edge to dismiss it (up for top positions, down for bottom positions). Passing the swipe threshold removes the toast, otherwise it animates back into place. Pressing Escape while focus is inside the toaster dismisses the newest dismissible toast.

Action buttons

Each toast can include a single action via :action or several via :actions. Every action declares a :type:

:typeWhat it doesRequired field
:eventPushes a LiveView event on click:event
:linkNavigatesone of :href, :navigate, :patch
:dismissCloses the toast, nothing else(none)

Link actions mirror Phoenix LiveView's <.link> vocabulary. Pick exactly one of :href, :navigate, or :patch:

# Full page load or external URL (plain anchor)
Fluxon.put_toast(socket, "Open the docs",
  action: %{label: "Docs", type: :link, href: "https://hexdocs.pm/..."})

# Cross-LiveView navigation (push_navigate)
Fluxon.put_toast(socket, "Account created",
  action: %{label: "View profile", type: :link, navigate: ~p"/profile"})

# Same-LiveView param update (push_patch)
Fluxon.send_toast(socket, "Filter applied",
  action: %{label: "Clear", type: :link, patch: ~p"/items"})

A :navigate action performs a client-side push_navigate, :patch performs a push_patch, and :href renders a normal anchor. LiveView intercepts the click and drives the navigation, so the action participates in client-side routing automatically.

Callback actions (send_toast/3 only)

Fluxon.send_toast(socket, "Item deleted",
  action: %{label: "Undo", type: :event, event: "undo", value: %{id: 42}})

Clicking the button pushes the named event to the LiveView, handled in your usual handle_event/3. The action auto-dismisses the toast after firing; pass dismiss: false to keep it open. As with every LiveView event, an atom-keyed :value map arrives string-keyed in handle_event/3 (%{id: 42} becomes %{"id" => 42}).

Dismiss-only actions

For labeled acknowledgments ("Got it", "OK") that close the toast without any other side effect, use type: :dismiss. Pair with dismissible: false so the close button is hidden and the labeled button is the single dismiss control; otherwise the toast offers two redundant ways to close.

Fluxon.put_toast(socket, "Welcome aboard",
  color: :info,
  description: "Tour the dashboard to get started.",
  dismissible: false,
  action: %{label: "Got it", type: :dismiss, position: :inline})

Position: inline vs below

Each action's :position controls whether it renders in the same row as the title (:inline) or stacked below the body (:below, the default). Inline actions sit on the right side of the row, before the close button. Inline pairs well with single-line toasts; below works better when there is a description or content.

Position is per-action, so you can mix them:

Fluxon.send_toast(socket, "New message",
  description: "From Sam, 2 minutes ago",
  actions: [
    %{label: "Reply", type: :event, event: "reply", variant: "solid", position: :inline},
    %{label: "Mark as read", type: :event, event: "mark_read", variant: "ghost", position: :below}
  ])

Multiple actions and variants

Fluxon.send_toast(socket, "Deploy ready",
  actions: [
    %{label: "Deploy",  type: :event, event: "deploy",  variant: "solid", position: :inline},
    %{label: "Discard", type: :event, event: "discard", variant: "ghost", position: :inline}
  ])

Action :variant accepts the same values as Fluxon.Components.Button: surface (default), outline, solid, soft, ghost, and link. The default surface reads as a soft-tinted background with a colored border, coordinated with the toast surface without requiring an explicit color. Actions also accept :disabled (boolean) and, for send_toast/3, an :icon HEEx fragment that renders to the left of the label.

Action color

Each action accepts a :color (:info, :success, :warning, or :danger) that tints every variant: solid background, soft background, surface background and border, ghost text, link text, and the outline's border and text. When omitted, the action inherits the toast's color, so a :success toast renders a green action button by default; a neutral toast falls back to the brand primary. The mechanism is the same nested-soft-elevation approach Fluxon.Components.Alert uses: the toast shadows the soft tokens for its children so the default surface button (and any other soft-tinted variant) renders against an elevated tier and pops instead of dissolving into the toast.

# Default surface variant plus inherited color: a green coordinated button.
Fluxon.send_toast(socket, "Saved",
  color: :success,
  action: %{label: "View", type: :link, href: ~p"/items/1"})

# Inherits the toast color: a red Retry on a red toast.
Fluxon.send_toast(socket, "Sync failed",
  color: :danger,
  action: %{label: "Retry", type: :event, event: "retry", variant: "solid"})

# Explicit override: a yellow Snooze on a red toast.
Fluxon.send_toast(socket, "Sync failed",
  color: :danger,
  action: %{label: "Snooze", type: :event, event: "snooze",
            variant: "solid", color: :warning})

Client-side trigger (phx-click)

Fluxon.send_toast/2, Fluxon.send_toast_loading/2, Fluxon.resolve_toast/3, and Fluxon.dismiss_toast/1 each return a Phoenix.LiveView.JS command, so you can show, resolve, or dismiss a toast straight from a phx-click with no server round-trip:

<.button phx-click={Fluxon.send_toast("Copied!", color: :success)}>
  Copy
</.button>

Chain with JS.push/3 to combine an immediate visual confirmation with a real server event:

<.button phx-click={
  JS.push("save_draft")
  |> Fluxon.send_toast("Saving...", color: :info, dismiss_after: 2_000)
}>
  Save
</.button>

HEEx content and icons still work because ~H evaluates server-side at template render time. The toast payload is embedded as the JS command's detail, so the trust chain matches send_toast/3: only the assigns available at template render time are captured. Use the socket form if you need to capture click-time state.

Rich content (send_toast/3 only)

Replace the default title and description block with arbitrary HEEx for custom layouts (avatars, tables, multi-line content, and so on).

Fluxon.send_toast(socket, "New follower",
  content: ~H"""
  <div class="flex items-center gap-3">
    <img src={@user.avatar} class="size-8 rounded-full" />
    <div>
      <p class="font-medium">{@user.name}</p>
      <p class="text-sm text-foreground-soft">started following you</p>
    </div>
  </div>
  """)

When :content is set the title and description are hidden so the custom markup owns the layout. The first positional argument (the title) is still used as the toast's accessible label, so pick a meaningful string.

Flash transport rejects HEEx

put_toast/3 raises if you pass :content or a HEEx :icon because the flash payload must serialize through a session cookie or signed token. Use send_toast/3 for any rich content.

Loading toasts

For async work, show an indeterminate loading toast and resolve it once the operation completes. The pair models a server-side promise: spin while you work, swap to a final state when done.

def handle_event("upload", _, socket) do
  {socket, id} = Fluxon.send_toast_loading(socket, "Uploading...")
  Task.start(fn -> upload_files(socket, id) end)
  {:noreply, socket}
end

def handle_info({:upload_done, id}, socket) do
  {:noreply, Fluxon.resolve_toast(socket, id, "Upload complete!", color: :success)}
end

def handle_info({:upload_failed, id, reason}, socket) do
  {:noreply,
   Fluxon.resolve_toast(socket, id, "Upload failed",
     color: :danger,
     description: reason)}
end

Loading toasts default to dismissible: false and dismiss_after: nil, so they stay visible until you call resolve_toast/4 or dismiss_toast/2. Both defaults can be overridden via opts. Calling resolve_toast/4 with an unknown id falls through to a fresh send_toast/3, so duplicate or late resolutions are harmless.

:color is optional on resolve: omit it to keep the toast's current color (handy when only the title or content changes mid-flight, e.g. a progress tick that stays :loading), or pass one to flip it (:loading to :success). The remaining opts behave exactly like send_toast/3.

Client-kicked loading

Fluxon.send_toast_loading/2 (and its %JS{} chain form) returns a Phoenix.LiveView.JS command, so a button can pop the spinner at the same instant it dispatches the server event, with no round-trip wait before the toast appears.

<.button phx-click={
  JS.push("upload") |> Fluxon.send_toast_loading("Uploading...", id: "upload-1")
}>
  Upload
</.button>

The server then resolves the same id once the work completes:

def handle_event("upload", _, socket) do
  Task.start(fn -> upload_files(socket, "upload-1") end)
  {:noreply, socket}
end

def handle_info({:upload_done, id}, socket) do
  {:noreply, Fluxon.resolve_toast(socket, id, "Upload complete!", color: :success)}
end

The JS-command form requires an explicit :id so the server has a stable handle to resolve against. Passing no id raises ArgumentError when the command is built. For fire-and-forget loading messages with no later resolution, use Fluxon.send_toast/2 with color: :loading.

Positions

<.toaster flash={@flash} position="top-right" />
PositionUse case
"top-right"Default. Least visually disruptive on desktop layouts.
"bottom-right"Common alternative; matches some chat and email apps.
"top-center"High-priority alerts that should not be missed.
"bottom-center"Mobile-friendly placement within thumb reach.
"top-left"Often paired with RTL layouts.
"bottom-left"Often paired with RTL layouts.

Top positions stack new toasts above older ones; bottom positions stack new toasts below.

Examples

Save and navigate

def handle_event("save", params, socket) do
  case Accounts.update_user(socket.assigns.user, params) do
    {:ok, user} ->
      {:noreply,
       socket
       |> Fluxon.put_toast("Profile updated", color: :success)
       |> push_navigate(to: ~p"/users/#{user}")}

    {:error, changeset} ->
      {:noreply, assign(socket, form: to_form(changeset))}
  end
end

Undo a destructive action

def handle_event("delete", %{"id" => id}, socket) do
  item = Repo.get!(Item, id) |> Repo.delete!()

  {:noreply,
   socket
   |> assign(deleted_item: item)
   |> Fluxon.send_toast("Item deleted",
     action: %{label: "Undo", type: :event, event: "restore", value: %{id: item.id}})}
end

def handle_event("restore", %{"id" => id}, socket) do
  {:ok, _} = Items.restore(id)
  {:noreply, Fluxon.send_toast(socket, "Item restored", color: :success)}
end
def handle_event("export", _, socket) do
  {socket, id} = Fluxon.send_toast_loading(socket, "Generating report...")
  Reports.async_generate(socket.assigns.current_user, reply_to: self(), toast_id: id)
  {:noreply, socket}
end

def handle_info({:report_ready, id, url}, socket) do
  {:noreply,
   Fluxon.resolve_toast(socket, id, "Report ready",
     color: :success,
     action: %{label: "Download", type: :link, href: url},
     dismiss_after: 30_000)}
end

Welcome after signup (controller)

def create(conn, params) do
  {:ok, user} = Accounts.register_user(params)

  conn
  |> log_in_user(user)
  |> Fluxon.put_toast("Welcome, #{user.name}!", color: :success)
  |> redirect(to: ~p"/dashboard")
end

Programmatic dismiss

# Dismiss a specific toast by id (e.g. after a background event resolves it).
Fluxon.dismiss_toast(socket, "upload-progress")

# Drop every queued (but not yet shown) flash toast.
Fluxon.clear_toasts(socket)

Limits and gotchas

Things to know

  • Place the toaster once, inside your app layout. Keep it inside the LiveView mount (e.g. Layouts.app/1), not in root.html.heex, so the hook can attach on live routes; dead-rendered pages are covered by the standalone runtime either way. The hook logs a console warning if it detects more than one toaster on the page.
  • Visible send_toast/3 toasts do not survive push_navigate/2. The toaster unmounts with the LiveView, so any on-screen toasts at the moment of navigation are dropped. Reach for put_toast/3 when the notification must persist across a redirect: flash is the only transport that rides through a push_navigate/2.
  • Auto-generated IDs are opaque. Pass an explicit :id whenever you intend to reference the toast later with dismiss_toast/2 or resolve_toast/4.
  • Same-id dedup is intentional. A toast id that is currently visible is silently rejected on re-show. Once dismissed, the same id can appear again, except for flash toasts, whose ids are also recorded per-tab (in sessionStorage, ids only) so a cached page restored via the back button does not replay an already-shown toast.

Summary

Components

Renders the toaster container that surfaces every toast for the page.

Components

toaster(assigns)

Renders the toaster container that surfaces every toast for the page.

Place this component exactly once in your app layout and pass @flash so flash-queued toasts from Fluxon.put_toast/3 surface after navigation. Toasts pushed via Fluxon.send_toast/3 arrive over the live socket and require no additional setup beyond mounting the toaster.

Once shown, a toast persists across LiveView reconnects and DOM patches without flicker, so an unrelated server update never disturbs a toast that is already on screen, and its dismiss timer resumes where it left off after a reconnect. See the module documentation for the full setup, transport, and behavior reference.

Attributes

  • id (:string) - Sets the DOM id of the toaster element. Override only if you have a naming collision; otherwise the default "fluxon-toaster" is fine.

    Defaults to "fluxon-toaster".

  • flash (:map) - The @flash assign from your layout. Required for Fluxon.put_toast/3 to surface toasts after navigation. Forgetting to pass this silently suppresses every flash-queued toast.

    Defaults to %{}.

  • position (:string) - Controls where the toast stack is anchored on screen.

    • "top-right": default, least visually disruptive on desktop layouts.
    • "bottom-right": common alternative; matches some chat and email apps.
    • "top-center": high-priority alerts that should not be missed.
    • "bottom-center": mobile-friendly placement within thumb reach.
    • "top-left": often paired with RTL layouts.
    • "bottom-left": often paired with RTL layouts.

    Top positions stack new toasts above older ones; bottom positions stack new toasts below.

    Defaults to "top-right". Must be one of "bottom-right", "top-right", "top-center", "bottom-center", "top-left", or "bottom-left".

  • max_toasts (:integer) - Caps how many toasts are visible at the same time. When the cap is hit and a new toast arrives, the oldest is removed to make room so the new one can enter immediately. Defaults to 5, which keeps the stack readable on most viewports.

    Defaults to 5.

  • dismiss_after (:integer) - Sets the default auto-dismiss timer (in milliseconds) used when an individual toast does not specify its own. Pass nil for persistent toasts that are only dismissed by user action or programmatically. Defaults to 4000.

    Defaults to 4000.

  • class (:any) - Additional Tailwind classes merged onto the toaster container. Use this to override the default width (max-w-sm), passing e.g. max-w-md, max-w-lg, or an arbitrary value like w-[420px]. Conflicting Tailwind utilities are resolved so the value you pass always replaces the matching default.

    Defaults to nil.