blog.src.rip

Introducing mini_toast.ex

A simple module you can copy into your Phoenix projects for better toast notifications.

10/5/2026

A history of LiveToast

I am the maintainer of a toast library for Phoenix LiveView, LiveToast. While working on this I’ve had a number of epiphanies about how to do toast-like notifications in Phoenix.

When I first created LiveToast, one of my major goals, which isn’t super relevant today, is I wanted to make it really easy for existing projects to adopt the library. An existing Phoenix project is liable to have hundreds of calls to put_flash littered throughout its codebase. When you make some new thing to replace flashes, I didn’t want people to have to go find and replace every single instance of put_flash with some new put_toast method.

I say it’s not super relevant because now an LLM agent can make that change with perfect accuracy across a huge codebase in like a couple minutes. But, it ended up being a nice selling point of LiveToast all the same that it worked with the existing flash system. It does this by making flashes and the newly introduced toast system look the same, so you can write new code that calls put_toast, and all your old code that calls put_flash won’t have the new benefits of the better toast system, but they will at least look the same.

What are those benefits exactly? Well, the biggest issue, is that the following does not really work with Phoenix flashes:

socket
|> put_flash(:info, "First message")
|> put_flash(:info, "Second message")

Only one of these will show up. I honestly don’t remember if its the first or the last one, but point being you can only see one message at a time at each severity level. The @flash object is a map, where the keys are the severity levels and the values are the messages. I think it could have been a list, and maybe in some future Phoenix may choose to make it a list and make this whole thing irrelevant, but for the time being it is a map.

Ironically, I kinda stumbled on the concept of having LiveToast integrate so deeply with the flash system like this, it wasn’t my initial starting point. But it turned out to have a lot of advantages.

I’ve often said it’s pretty trivial to hot-wire in some kind of new toast system to Phoenix. Sonner for instance, is an absolutely beautiful React based toast library. You can write up a LiveView Hook in like 15 minutes that will allow you to send toasts to a react component like Sonner, either from the client or the LiveView server. It will look beautiful! But it will have one pretty big downside.

It’s a very common pattern to want to assert on some content of a flash message in your tests:

test "submitting the form works", %{conn: conn} do
  {:ok, view, _html} = live(conn, ~p"/settings")

  html =
    view
    |> form("#settings-form", settings: %{name: "Andrew"})
    |> render_submit()

  assert html =~ "Settings saved."
end

If you take the easy path of implementing a nice client side toast library, you no longer have any easy path to writing tests like this, because your flash/toasts now rely on JavaScript.

Secondly, and probably more importantly, you will very often want to navigate and push a toast/flash onto the assigns at the same time. If you use some JS-centric method, it is very difficult to make it work properly across navigations.

It is for these two reasons, that I believe the right path is to come up with a replacement for flashes that nonetheless falls-back to using flashes when JS is not available. This is essentially what LiveToast ended up doing, but I am presenting to you here another alternative I am calling mini_toast.ex.

Presenting mini_toast.ex

The basic idea of mini_toast.ex is that rather than being a library, it’s a simple file you can copy into any modern Phoenix/LiveView project, own it, add whatever styling and additional features you want, etc., and you have a pretty good starting point for your own toast implementation if you’d rather not use LiveToast and aren’t satisfied with any of the other options out there.

Note: The version I’ll present below utilizes colocated hooks added in LiveView 1.1, but if you’re on an earlier version you can just put the JS in your regular app.js file and everything else is the same.

Here’s my minimalist starting point for you:

defmodule MyAppWeb.MiniToast do
  @moduledoc """
  Minimalist replacement for `put_flash` that lets you render toast notifications
  that fall back to flashes when that matters, such as navigations and tests.

  Replace your existing `flash_group` function with `toast_group` like so:

    <MyAppWeb.MiniToast.toast_group
      flash={@flash}
      toasts={assigns[:toasts] || []}
    />

  """

  use Phoenix.Component

  alias Phoenix.LiveView
  alias Phoenix.LiveView.ColocatedHook
  alias Phoenix.LiveView.JS
  alias Phoenix.LiveView.Socket
  alias Plug.Conn

  @kinds [:info, :success, :warning, :error]

  @enter_transition {
    "transition-all ease-out duration-200 motion-reduce:transition-none",
    "opacity-0 translate-y-2 scale-95",
    "opacity-100 translate-y-0 scale-100"
  }

  @leave_transition {
    "transition-all ease-in duration-150 motion-reduce:transition-none",
    "opacity-100 translate-x-0",
    "opacity-0 translate-x-2"
  }

  @doc """
  This is a server hook you need to add to your `my_app_web.ex` file like so:

    def live_view do
      quote do
        use Phoenix.LiveView,
          layout: {MyAppWeb.Layouts, :app}

        on_mount MyAppWeb.MiniToast

        unquote(html_helpers())
      end
    end

  """
  def on_mount(:default, _params, _session, socket) do
    socket =
      socket
      |> assign_new(:toasts, fn -> [] end)
      |> LiveView.attach_hook(
        :mini_toast,
        :handle_event,
        &handle_event/3
      )

    {:cont, socket}
  end

  @doc """
  Add a toast. Use this wherever you would previously call `put_flash`.

  There is a second version that takes a %Conn{}, so you can still use `put_toast`
  in controllers and it simply behaves as `put_flash`.
  """
  def put_toast(target, kind, message, opts \\ [])

  def put_toast(%Socket{} = socket, kind, message, opts) do
    message = to_string(message)
    title = Keyword.get(opts, :title)

    toast = %{
      id: "toast-#{System.unique_integer([:positive, :monotonic])}",
      kind: kind,
      title: title,
      message: message
    }

    socket
    |> LiveView.put_flash(kind, flash_value(title, message))
    |> update(:toasts, &[toast | &1])
  end

  def put_toast(%Conn{} = conn, kind, message, opts) do
    message = to_string(message)
    title = Keyword.get(opts, :title)

    Phoenix.Controller.put_flash(
      conn,
      kind,
      flash_value(title, message)
    )
  end

  attr(:flash, :map, required: true)
  attr(:toasts, :list, default: [])
  attr(:duration, :integer, default: 5_000)

  @doc "Replaces the default Phoenix `flash_group` component."
  def toast_group(assigns) do
    assigns =
      assign(
        assigns,
        :items,
        merge_toasts(assigns.toasts, assigns.flash)
      )

    ~H"""
    <div
      id="toast-group"
      class="pointer-events-none fixed inset-x-4 bottom-4 z-50 grid gap-3 sm:left-auto sm:right-4 sm:w-full sm:max-w-sm"
      aria-live="polite"
      aria-relevant="additions"
    >
      <.toast
        :for={toast <- @items}
        id={toast.id}
        kind={toast.kind}
        title={toast.title}
        message={toast.message}
        is_flash={toast.is_flash}
        duration={@duration}
        phx-mounted={mount_toast(toast, @flash)}
      />
    </div>
    """
  end

  attr(:id, :string, required: true)
  attr(:kind, :atom, required: true, values: @kinds)
  attr(:title, :string, default: nil)
  attr(:message, :string, required: true)
  attr(:is_flash, :boolean, default: false)
  attr(:duration, :integer, required: true)
  attr(:rest, :global)

  @doc "Renders an individual toast or fallback flash."
  def toast(assigns) do
    ~H"""
    <div
      id={@id}
      phx-hook=".MiniToastAutoDismiss"
      data-duration={@duration}
      role={if @kind in [:warning, :error], do: "alert", else: "status"}
      class={[
        "pointer-events-auto relative flex items-start gap-3 overflow-hidden rounded-lg border p-4 pr-11 text-sm shadow-lg shadow-zinc-950/5",
        @kind == :info &&
          "border-zinc-200/80 bg-white text-zinc-950 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-50",
        @kind == :success &&
          "border-emerald-200/80 bg-emerald-50 text-emerald-950 dark:border-emerald-900/80 dark:bg-emerald-950 dark:text-emerald-50",
        @kind == :warning &&
          "border-amber-200/80 bg-amber-50 text-amber-950 dark:border-amber-900/80 dark:bg-amber-950 dark:text-amber-50",
        @kind == :error &&
          "border-red-200/80 bg-red-50 text-red-950 dark:border-red-900/80 dark:bg-red-950 dark:text-red-50"
      ]}
      {@rest}
    >
      <div class="min-w-0 flex-1">
        <p :if={@title} class="font-medium leading-5">
          {@title}
        </p>

        <p class="leading-5 opacity-80">
          {@message}
        </p>
      </div>

      <button
        type="button"
        aria-label="Dismiss notification"
        phx-click={dismiss_toast(@id, @kind, @is_flash)}
        class="absolute right-2 top-2 grid size-7 place-items-center rounded-md text-lg leading-none text-current opacity-50 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
      >
        <span aria-hidden="true">&times;</span>
      </button>
    </div>

    <script
      :type={ColocatedHook}
      name=".MiniToastAutoDismiss"
      extension="ts"
    >
      type MiniToastAutoDismissHook = {
        el: HTMLElement
        timer?: ReturnType<typeof setTimeout>
      }

      export default {
        mounted(this: MiniToastAutoDismissHook) {
          const duration = Number(this.el.dataset.duration)

          if (!Number.isFinite(duration) || duration <= 0) return

          this.timer = setTimeout(() => {
            this.el
              .querySelector<HTMLButtonElement>(
                "button[aria-label='Dismiss notification']"
              )
              ?.click()
          }, duration)
        },

        destroyed(this: MiniToastAutoDismissHook) {
          if (this.timer) clearTimeout(this.timer)
        }
      }
    </script>
    """
  end

  @doc """
  Combines assigned toasts with Phoenix flashes.

  A flash is omitted when an assigned toast has the same kind, title, and message:

      iex> [result] =
      ...>   MyAppWeb.MiniToast.merge_toasts(
      ...>     [%{id: "toast-1", kind: :info, message: "Saved"}],
      ...>     %{"info" => "Saved"}
      ...>   )
      iex> {result.id, result.kind, result.message, result.is_flash}
      {"toast-1", :info, "Saved", false}

  A flash without an assigned toast becomes a fallback toast:

      iex> [result] =
      ...>   MyAppWeb.MiniToast.merge_toasts(
      ...>     [],
      ...>     %{"error" => "Something went wrong"}
      ...>   )
      iex> {result.kind, result.message, result.is_flash}
      {:error, "Something went wrong", true}

  Messages of the same kind are not deduplicated unless their contents match:

      iex> results =
      ...>   MyAppWeb.MiniToast.merge_toasts(
      ...>     [%{id: "toast-1", kind: :info, message: "Draft saved"}],
      ...>     %{"info" => "Published"}
      ...>   )
      iex> Enum.map(results, &{&1.message, &1.is_flash})
      [{"Draft saved", false}, {"Published", true}]

  """
  def merge_toasts(toasts, flash) do
    toasts = Enum.map(toasts, &normalize_toast/1)

    fallback_flashes =
      for kind <- @kinds,
          notification = flash_notification(flash, kind),
          notification,
          not Enum.any?(toasts, &same_notification?(&1, notification)) do
        notification
      end

    toasts ++ fallback_flashes
  end

  defp normalize_toast(toast) do
    toast
    |> Map.put_new(:title, nil)
    |> Map.put(:is_flash, false)
  end

  defp flash_notification(flash, kind) do
    case Phoenix.Flash.get(flash, kind) do
      nil ->
        nil

      %{message: message} = notification ->
        new_flash_notification(
          kind,
          Map.get(notification, :title),
          message
        )

      %{"message" => message} = notification ->
        new_flash_notification(
          kind,
          Map.get(notification, "title"),
          message
        )

      message ->
        new_flash_notification(kind, nil, message)
    end
  end

  defp new_flash_notification(kind, title, message) do
    title = normalize_title(title)
    message = to_string(message)

    %{
      id: flash_id(kind, title, message),
      kind: kind,
      title: title,
      message: message,
      is_flash: true
    }
  end

  defp flash_id(kind, title, message) do
    hash = :erlang.phash2({title, message})
    "flash-#{kind}-#{hash}"
  end

  defp same_notification?(nil, _notification), do: false

  defp same_notification?(left, right) do
    left.kind == right.kind and
      left.title == right.title and
      left.message == right.message
  end

  defp normalize_title(nil), do: nil
  defp normalize_title(title), do: to_string(title)

  defp flash_value(nil, message), do: message

  defp flash_value(title, message) do
    %{
      title: to_string(title),
      message: message
    }
  end

  # Remove a toast from the server, and hide it optimistically.
  defp dismiss_toast(id, _kind, false) do
    JS.push("mini-toast:dismiss", value: %{id: id})
    |> hide_toast(id)
  end

  # Remove a flash from the server, and hide it optimistically.
  defp dismiss_toast(id, kind, true) do
    JS.push("lv:clear-flash", value: %{key: kind})
    |> hide_toast(id)
  end

  defp mount_toast(toast, flash) do
    JS.transition(
      @enter_transition,
      to: "##{toast.id}",
      time: 200
    )
    |> clear_matching_flash(toast, flash)
  end

  defp hide_toast(js, id) do
    JS.hide(
      js,
      to: "##{id}",
      time: 150,
      transition: @leave_transition
    )
  end

  # put_toast/3 creates both an assigned toast and a fallback flash. Once the
  # assigned toast mounts successfully, clear its matching fallback.
  #
  # Compare the kind, title, and message because a later put_flash/3 call may
  # have replaced the fallback with a different notification of the same kind.
  defp clear_matching_flash(js, %{is_flash: false} = toast, flash) do
    notification = flash_notification(flash, toast.kind)

    if same_notification?(notification, toast) do
      JS.push(js, "lv:clear-flash", value: %{key: toast.kind})
    else
      js
    end
  end

  defp clear_matching_flash(js, _toast, _flash), do: js

  defp handle_event("mini-toast:dismiss", %{"id" => id}, socket) do
    toasts =
      Enum.reject(socket.assigns.toasts, fn toast ->
        toast.id == id
      end)

    {:halt, assign(socket, :toasts, toasts)}
  end

  defp handle_event(_event, _params, socket) do
    {:cont, socket}
  end
end

I would save this file in a location like lib/my_app_web/components/mini_toast.ex, name the module accordingly, and then set up using it:

You would then go to your flash_group, and replace it like:

<MyAppWeb.MiniToast.toast_group
  flash={@flash}
  toasts={assigns[:toasts] || []}
/>

And then go your main web module, in this example my_web.ex:

def live_view do
  quote do
    use Phoenix.LiveView,
      layout: {MyAppWeb.Layouts, :app}

    on_mount MyAppWeb.MiniToast

    unquote(html_helpers())
  end
end

There is a colocated hook here as well, and so long as your app is setup right it should just work, but you could extract the hook out as well.

Now, this is quite a bit of code, about 400 lines, so maybe you don’t want to use the word minimal, but the point is this is a file you can just copy in to your project and own, rather than a library. If that sort of thing matters to you. Although you should try toast.src.rip too if you haven’t.

Anyways, this system has a couple major properties:

  • You can render more than one toast per a given severity level.
  • Flashes will continue to work, although you probably should go find-and-replace all instances of put_flash with the new put_toast.
  • put_toast inserts into both the @toasts and @flash assign, and is then de-duplicated in such a way that navigations will end up showing the flash still.

Followed by a couple nice details:

  • You have 4 severity levels now, :info, :success, :warning, and :error, instead of the default :info and :error.
  • There’s a little bit of entrance/exit animation using Phoenix.JS Commands.
  • The hook is used primarily to drive one feature that you pretty much need JavaScript for—setting a timer when a toast/flash is shown and auto-dismissing it after a certain period of time (in this example, 5 seconds).
  • Like the default flash component in your core_components, you’ve got optional @title attributes.
  • There’s a close button on the toast/flash which will properly dismiss either/or, and will do so by optimistically using a Phoenix.JS Command to hide it in the DOM followed by emitting an event to clear it for real.

Originally, when writing this article, I thought about showing you the most minimal version of this and adding parts on to it, but I realized if you are here, you probably just want to copy the whole thing. The bells and whistles in this example though are the bells and whistles I personally find helpful. I like auto-dismissal for instance, so I’ve added that. But I don’t, for instance, have pausing the auto-dismissal on mouseover, cause I don’t personally really like it, but you can easily add in whatever you want here. That’s the benefit of a module you can just copy in to your project.

Furthermore, I have a little Phoenix example of this in action I’m working on getting up on GitHub. I’ll be putting that online soon for you to check out, so you could just clone that repo and see it quickly in action, or I might host it online too I don’t know.

Changes to this moving forward

If anything changes with this little example I’ll keep it up to date here, and on GitHub when I add the example here.