Toast

Transient notification that stacks, auto-dismisses, and can be swiped to close.

Preview component

This component is available as a preview. Its API, behavior, and styling may change in future releases without prior deprecation.

Basic

A button pushes an event, the LiveView responds with Fluxon.send_toast/3. No client state, no JS.

.ex
def handle_event("save", _, socket) do
  {:noreply, Fluxon.send_toast(socket, "Saved!")}
end

Semantic colors

Pass color: :info | :success | :warning | :danger to drive the surface tint and the leading icon. Omit color for the neutral default; danger toasts are announced assertively for screen readers.

.ex
Fluxon.send_toast(socket, "New version available", color: :info)
Fluxon.send_toast(socket, "Changes published", color: :success)
Fluxon.send_toast(socket, "Approaching storage limit", color: :warning)
Fluxon.send_toast(socket, "Failed to save changes", color: :danger)

Title and description

Pass description: to render secondary text below the title.

.ex
Fluxon.send_toast(socket, "Article published",
  color: :success,
  description: "Your article is live and visible to subscribers."
)

Custom dismiss timer

Override the toaster's default timer with dismiss_after: 10_000 (milliseconds) when the message needs more reading time. Hovering anywhere on the toaster pauses every running timer.

.ex
Fluxon.send_toast(socket, "Take your time reading this",
  color: :info,
  dismiss_after: 10_000
)

Persistent until dismissed

Pass dismiss_after: nil to keep the toast visible until it is closed manually, or until Fluxon.dismiss_toast/2 is called.

.ex
Fluxon.send_toast(socket, "Scheduled maintenance tonight",
  color: :warning,
  description: "Expect brief downtime between 10pm and 11pm UTC.",
  dismiss_after: nil
)

Non-dismissible

Set dismissible: false to hide the close button. The toast then disappears only on auto-dismiss or via Fluxon.dismiss_toast/2.

.ex
Fluxon.send_toast(socket, "Reconnecting...",
  color: :info,
  dismissible: false,
  dismiss_after: 3_000
)

Hide the icon

Pass icon: false to suppress the leading color icon for a denser, text-only toast.

.ex
Fluxon.send_toast(socket, "Copied to clipboard", icon: false)

Use type: :link with :href, :navigate, or :patch to choose how the action navigates: plain anchor, cross-LiveView push, or same-LiveView patch.

Callback action

An action with type: :event pushes the named event back to the LiveView when clicked, then auto-dismisses the toast. The canonical pattern is a destructive action paired with an Undo affordance.

.ex
def handle_event("delete", %{"id" => id}, socket) do
  {:noreply,
   Fluxon.send_toast(socket, "Item deleted",
     description: "Project moved to trash.",
     action: %{label: "Undo", type: :event, event: "restore", value: %{id: id}}
   )}
end

def handle_event("restore", %{"id" => id}, socket) do
  {:noreply, Fluxon.send_toast(socket, "Restored item ##{id}", color: :success)}
end

Multiple actions with variants

Pass actions: with a list of action maps. Each accepts a Button variant: so a primary action can sit next to a quieter secondary one.

.ex
Fluxon.send_toast(socket, "Deploy ready",
  color: :info,
  description: "Build #4821 passed all checks.",
  actions: [
    %{label: "Deploy", type: :event, event: "deploy", variant: "solid"},
    %{label: "Discard", type: :event, event: "discard", variant: "ghost"}
  ]
)

Inline action layout

Each action's position: controls where it renders. The default is :below (stacked under the title). Pass position: :inline to place the button in the same row as the title. Pair with dismissible: false when the inline action is itself a meaningful close affordance, so the X doesn't crowd the row.

.ex
Fluxon.send_toast(socket, "User added successfully",
  color: :success,
  dismissible: false,
  action: %{
    label: "View",
    type: :link,
    href: ~p"/users/1",
    position: :inline,
    variant: "solid"
  }
)

Dismiss-only action

For labeled acknowledgments ("Got it", "OK") that close the toast with no other side effect, use type: :dismiss. Pair it with dismissible: false so the X is hidden and the labeled button is the only way to close, otherwise you end up with two redundant dismiss controls.

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

Keep open after action

Action clicks dismiss the toast by default. Pass dismiss: false on an action to keep the toast visible after firing, for retry or multi-step flows.

.ex
Fluxon.send_toast(socket, "Sync failed",
  color: :danger,
  description: "We could not reach the upstream server.",
  action: %{
    label: "Retry",
    type: :event,
    event: "retry_sync",
    dismiss: false,
    variant: "solid"
  }
)

Rich HEEx content

Pass content: with a HEEx fragment to replace the title and description with arbitrary markup (avatars, multi-line layouts, custom widgets). The first argument is still used as the toast's accessible label. send_toast/3 only.

.ex
def handle_event("new_follower", _, socket) do
  assigns = %{user: socket.assigns.follower}

  content = ~H"""
  <div class="flex items-center gap-3">
    <img src={@user.avatar_url} class="size-9 rounded-full" />
    <div class="min-w-0">
      <p class="text-sm font-medium text-foreground">{@user.name}</p>
      <p class="text-xs text-foreground-soft">started following you</p>
    </div>
  </div>
  """

  {:noreply,
   Fluxon.send_toast(socket, "New follower",
     content: content,
     action: %{label: "Follow back", type: :event, event: "follow_back", variant: "soft"}
   )}
end

Loading toast resolved to success

Fluxon.send_toast_loading/3 shows an indeterminate spinner and returns {socket, id}. After the async work completes, Fluxon.resolve_toast/4 swaps it for a final color and animates the transition.

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

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

Loading toast resolved to error

Same lifecycle, resolved with :danger and a description: explaining the failure.

.ex
{:noreply,
 Fluxon.resolve_toast(socket, id, "Payment declined", color: :danger,
   description: "The card was rejected by the issuer."
 )}

Loading resolved with action

Resolutions accept the same options as send_toast/3, including action: and a longer dismiss_after: so the result stays on screen long enough to click through.

.ex
{:noreply,
 Fluxon.resolve_toast(socket, id, "Report ready", color: :success,
   action: %{label: "Download", type: :link, href: report_url},
   dismiss_after: 30_000
 )}

Multi-step progress

Call resolve_toast/4 repeatedly with :loading to update the same toast as work moves through phases, then resolve to a final color when done. One toast changes titles in place instead of stacking a flurry of separate notifications.

.ex
def handle_event("deploy", _, socket) do
  {socket, id} = Fluxon.send_toast_loading(socket, "Pushing to remote...")
  Process.send_after(self(), {:deploy_step, id, :build}, 1_200)
  {:noreply, socket}
end

def handle_info({:deploy_step, id, :build}, socket) do
  Process.send_after(self(), {:deploy_step, id, :deploy}, 1_200)

  {:noreply,
   Fluxon.resolve_toast(socket, id, "Building image...", color: :loading,
     dismissible: false,
     dismiss_after: nil
   )}
end

def handle_info({:deploy_step, id, :deploy}, socket) do
  Process.send_after(self(), {:deploy_step, id, :done}, 1_200)

  {:noreply,
   Fluxon.resolve_toast(socket, id, "Deploying to staging...", color: :loading,
     dismissible: false,
     dismiss_after: nil
   )}
end

def handle_info({:deploy_step, id, :done}, socket) do
  {:noreply,
   Fluxon.resolve_toast(socket, id, "Live on staging", color: :success,
     description: "https://staging.example.com",
     action: %{label: "Open", type: :link, href: "https://staging.example.com"}
   )}
end

Custom progress bar

The built-in progress bar tracks the dismiss timer. For real progress (uploads, batched work) render your own bar inside :content and call resolve_toast/4 with :loading each tick. When the work finishes, resolve to :success with a link to the result.

.ex
def handle_event("upload_photos", _, socket) do
  total = 12
  {socket, id} = Fluxon.send_toast_loading(socket, "Uploading photos",
    content: progress_content(%{current: 0, total: total}),
    id: "photo-upload"
  )

  Process.send_after(self(), {:photo_step, id, 1, total}, 250)
  {:noreply, socket}
end

def handle_info({:photo_step, id, current, total}, socket) when current < total do
  Process.send_after(self(), {:photo_step, id, current + 1, total}, 250)

  {:noreply,
   Fluxon.resolve_toast(socket, id, "Uploading photos", color: :loading,
     content: progress_content(%{current: current, total: total}),
     dismissible: false,
     dismiss_after: nil
   )}
end

def handle_info({:photo_step, id, total, total}, socket) do
  {:noreply,
   Fluxon.resolve_toast(socket, id, "Upload complete", color: :success,
     description: "#{total} photos added to your album.",
     action: %{label: "View album", type: :link, href: "#", position: :inline}
   )}
end

# Renders the title row + progress bar.
defp progress_content(assigns) do
  ~H"""
  <div class="space-y-2">
    <div class="flex items-center justify-between gap-3">
      <span class="text-sm font-medium text-foreground">Uploading photos</span>
      <span class="text-xs text-foreground-soft tabular-nums">
        {@current} / {@total}
      </span>
    </div>
    <div class="h-1.5 w-full rounded-full bg-foreground/10 overflow-hidden">
      <div
        class="h-full bg-info rounded-full transition-[width] duration-200 ease-out"
        style={"width: " <> Integer.to_string(div(@current * 100, @total)) <> "%"}
      />
    </div>
  </div>
  """
end

Programmatic dismiss by id

Pass an explicit :id when sending the toast, then call Fluxon.dismiss_toast/2 with that id to close it later from anywhere in your code (background events, scheduled cleanup, etc.).

.ex
Fluxon.send_toast(socket, "Pinned notification",
  color: :info,
  dismiss_after: nil,
  id: "demo-pinned"
)

# Later, from anywhere with access to the socket:
Fluxon.dismiss_toast(socket, "demo-pinned")

Client-side trigger

Fluxon.send_toast/2 returns a %JS{} command, so a toast can fire straight from phx-click with no server event. Ideal for purely UI feedback ("Copied!", "Saved", "Removed") where the action lives entirely in the browser. The payload is server-rendered at template time, so HEEx content and icons still work the same as the socket form.

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

Chain a toast with a server event

Compose Fluxon.send_toast/2 into an existing JS pipeline for optimistic feedback. The toast appears immediately while the server event flies in parallel; the server can then send a follow-up toast with the same id to confirm or override the outcome.

.heex
<.button phx-click={
  JS.push("save_draft")
  |> Fluxon.send_toast("Saving draft...",
    color: :info,
    dismiss_after: 1_500,
    id: "draft-save"
  )
}>
  Save draft
</.button>

# Server side: replaces the optimistic toast with a confirmation
# by reusing the same :id.
def handle_event("save_draft", _, socket) do
  # ... persist the draft ...
  {:noreply,
   Fluxon.send_toast(socket, "Draft saved",
     color: :success,
     id: "draft-save"
   )}
end

Dismiss a toast from a phx-click

Fluxon.dismiss_toast/1 returns a %JS{} command keyed to a toast id. Pair it with a server-shown persistent toast to give the user a way to clear it from anywhere on the page; the dismiss runs entirely in the browser, no round-trip needed.

.heex
# Server pushes a persistent toast with a known id.
def handle_event("show_banner", _, socket) do
  {:noreply,
   Fluxon.send_toast(socket, "New version available",
     color: :info,
     dismiss_after: nil,
     id: "version-banner"
   )}
end

# Anywhere in the template, phx-click closes it without a round-trip.
<.button phx-click={Fluxon.dismiss_toast("version-banner")}>
  Hide it
</.button>

Stack cap

The toaster caps the visible stack at max_toasts (default 5). When the cap is hit, the oldest toast animates out to make room.

.ex
# Toaster setup
<.toaster flash={@flash} max_toasts={5} />

# Server side: just call send_toast as fast as you like.
# Anything beyond max_toasts pushes the oldest visible one out.
for i <- 1..8 do
  Fluxon.send_toast(socket, "Notification ##{i}", color: :info)
end

Custom width

Pass any Tailwind width utility through the class attr to override the default max-w-sm, for example max-w-md, max-w-lg, or an arbitrary value like w-[420px].

.heex
<%!-- Append a Tailwind width class and key the toaster by it so
     the element remounts when the value changes. --%>
<.toaster
  id={"fluxon-toaster-" <> (@toaster_width || "sm")}
  flash={@flash}
  class={"max-w-" <> (@toaster_width || "sm")}
/>
.ex
def handle_params(params, _uri, socket) do
  width = params["toaster_width"] || "sm"
  {:noreply, assign(socket, :toaster_width, width)}
end

def handle_event("set_width", %{"width" => width}, socket) do
  {:noreply, push_patch(socket, to: ~p"/components/toast?toaster_width=#{width}")}
end

Switch position at runtime

The position attr drives where the stack anchors. The toaster element opts out of LiveView patching (phx-update="ignore"), so swapping an assign will not re-render it. Drive position from a URL parameter and key the toaster element by position; LiveView then tears down the old node and mounts a fresh one.

.heex
<%!-- Key the toaster element by position so it remounts when position
     changes; phx-update="ignore" blocks attribute morphs. --%>
<.toaster
  id={"fluxon-toaster-" <> (@toaster_position || "top-right")}
  flash={@flash}
  position={@toaster_position || "top-right"}
/>
.ex
def handle_params(params, _uri, socket) do
  pos = params["toaster_pos"] || "top-right"
  {:noreply, assign(socket, :toaster_position, pos)}
end

def handle_event("set_position", %{"pos" => pos}, socket) do
  {:noreply, push_patch(socket, to: ~p"/components/toast?toaster_pos=#{pos}")}
end

Survives push_navigate

Fluxon.put_toast/3 rides on Phoenix flash, so the toast persists across push_navigate/2, controller redirects, and full-page reloads. Reach for it when the response navigates and the notification should follow.

.ex
def handle_event("save", _, socket) do
  {:noreply,
   socket
   |> Fluxon.put_toast("Profile updated", color: :success)
   |> push_navigate(to: ~p"/dashboard")}
end

Setup

Mount the toaster exactly once in your root layout and pass @flash. Available positions are top-right (default), top-left, top-center, bottom-right, bottom-left, and bottom-center. Top positions stack newer toasts above older ones; bottom positions stack newer below.

The toaster is already mounted in this site's layout. Click any preview above to see it surface a toast.

.ex
# lib/my_app_web/components/layouts/app.html.heex
<.toaster flash={@flash} position="top-right" max_toasts={5} dismiss_after={4000} />