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.
| Function | Survives navigation | Works from controllers | HEEx content | Callback actions |
|---|---|---|---|---|
Fluxon.put_toast/3 | yes | yes | no | no |
Fluxon.send_toast/3 | no | no | yes | yes |
Fluxon.send_toast_loading/3 | no | no | yes | yes |
Fluxon.resolve_toast/4 | no | no | yes | yes |
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}})}
endFrom a Controller
def create(conn, params) do
# ... save the record ...
conn
|> Fluxon.put_toast("Welcome aboard!", color: :success)
|> redirect(to: ~p"/dashboard")
endColors
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| Color | Use case |
|---|---|
:info | Informational nudges, e.g. "New version available". |
:success | Positive confirmations such as "Saved" or "Sent". |
:warning | Non-blocking caution like "Approaching quota". |
:danger | Failures 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 to4000. Passnilfor persistent toasts.:dismissible: whether the user can dismiss (close button, swipe, Escape). Defaults totrue.- Per-action
:dismiss: whether clicking an action dismisses the toast. Defaults totrueper 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:
:type | What it does | Required field |
|---|---|---|
:event | Pushes a LiveView event on click | :event |
:link | Navigates | one of :href, :navigate, :patch |
:dismiss | Closes the toast, nothing else | (none) |
Link actions
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)}
endLoading 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)}
endThe 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" />| Position | Use 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
endUndo 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)}
endAsync job with a download link on completion
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)}
endWelcome 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")
endProgrammatic 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 inroot.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/3toasts do not survivepush_navigate/2. The toaster unmounts with the LiveView, so any on-screen toasts at the moment of navigation are dropped. Reach forput_toast/3when the notification must persist across a redirect: flash is the only transport that rides through apush_navigate/2. - Auto-generated IDs are opaque. Pass an explicit
:idwhenever you intend to reference the toast later withdismiss_toast/2orresolve_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
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@flashassign from your layout. Required forFluxon.put_toast/3to 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 to5, 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. Passnilfor persistent toasts that are only dismissed by user action or programmatically. Defaults to4000.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 likew-[420px]. Conflicting Tailwind utilities are resolved so the value you pass always replaces the matching default.Defaults to
nil.