Turbo Flash Notices

HTTP Alert

An alert from a standard HTTP response.

Turbo Frame Alert

An alert after a turbo frame interaction.

Turbo Stream Alert

An alert from a turbo stream response.

HTTP Toast

A toast from an HTTP response.

Turbo Frame Toast

A toast from a turbo frame interaction.

Turbo Stream Toast

A toast from a turbo stream response.

How it All Works

My goal is to show you how you can have all these options for flash[:notice] at your command in your application. You should be able to use an alert when you want, a toast when you want, and make everything just work independent of how the interaction was conducted, whether through a standard HTTP request, a Turbo Frame interaction, or a Turbo Stream.

Here's some ideas for this common UX pattern in your Ruby on Rails application. The source of this application demonstrates these practices, however, because everything is happening without forms, validations, and from a single page, the code is a little contrived and different than what you will find below, but the premise is the same.

I hope this demo inspires you with some ideas for your application.

Starting with a Standard HTTP Alert

You generally want to use an "HTTP" Alert when the application is sending back a standard HTTP response to the browser, so not through a Turbo interaction. This is common when you are for instance displaying a form, the user submits it, and you want to redirect to the page and let them know it was successful or render the form again with the flash[:notice] appearing above it telling the user what validations failed.

The controller code that caused that alert to be displayed is generally something like:

class JobPostsController < ApplicationController
  def create
    @job_post = JobPost.new(params[:job_post])
    if @job_post.save
      redirect_to job_post_path(@job_post), notice: "Job Post created successfully."
    else
      render :new
    end
  end
end

Generally, you are embedding the alert on a per-view basis. So your alert template is not part of your layout, but rather you place it in the view where you want it to appear, like above the form.

<% if @job_post_form.errors.present? %>
  <div role="alert">
    <div>
      <span>A Job Post must have:</span>
      <ul>
        <% @job_post_form.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  </div>
<% end %>

This literal alert seperates the concept of an alert from flash[:notice] and means you have to embed your alert HTML in every view, which is a lot. Generally the only thing in the alert that is changing is the list of validation errors. What I did starts treating the flash in a bit more structured way and used a helper to keep writing out my flash DRY.

The first thing I tend to do is construct my flash objects with a bit more metadata. In my controllers, I tend to write the flash something like this:

flash[:notice] = {
  type: :alert,
  status: :success,
  title: "Form submitted successfully!",
  message: "Your object has been created."
}

This allows me to tell my application the type of notice I want to display, an alert or a toast (which we'll discuss in a bit), the status of it, and provide an optional title or message from the controller.

To render my flash hash, I write a helper method and a re-usable partial. The helper method generally looks something like:

module FlashHelper
  def flash_alert(&block)
    return tag.div id: "alert" unless flash[:notice].present?
    flash[:notice].symbolize_keys!

    if flash[:notice][:type].to_sym == :alert
      render "layouts/alert", &block
    end
  end
end

That pairs with a partial that might look something like:

<% case flash[:notice][:status]
when "warning"
  bg_color = "bg-yellow-50"
  text_color = "text-yellow-800"
  icon_svg =
    '<svg class="w-5 h-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>'
when "error"
  bg_color = "bg-red-50"
  text_color = "text-red-800"
  icon_svg =
    '<svg class="w-5 h-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>'
when "success"
  bg_color = "bg-green-50"
  text_color = "text-green-800"
  icon_svg =
    '<svg class="w-5 h-5 text-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>'
when "info"
  bg_color = "bg-blue-50"
  text_color = "text-blue-800"
  icon_svg =
    '<svg class="w-5 h-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /></svg>'
else
  bg_color = "bg-gray-50"
  text_color = "text-gray-800"
  icon_svg =
    '<svg class="w-5 h-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm-4-8a4 4 0 118 0 4 4 0 01-8 0z" clip-rule="evenodd" /></svg>'
end %>

<div 
  id="alert"
  data-controller="alert"
  class="<%= bg_color %> fixed z-50 left-2 top-1 w-[96%] shadow-md sm:w-full sm:top-0 sm:left-0 sm:shadow-none sm:relative my-4 rounded-md p-4">
  <div class="flex">
    <div class="flex-shrink-0">
      <%= icon_svg.html_safe %>
    </div>
    <div class="ml-3">
      <% if flash[:notice][:title].present? %>
        <h3 class="text-sm font-medium <%= text_color %>"><%= flash[:notice][:title] %></h3>
      <% end %>
      <div class="mt-2 text-sm <%= text_color %>">
        <% if flash[:notice][:message].present? %>
          <p><%= flash[:notice][:message] %></p>
        <% end %>

        <%= yield %>
      </div>
    </div>
    <button type="button" data-action="alert#remove" class="sm:hidden ms-auto -mx-1.5 -my-1.5 text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-warning" aria-label="Close">
      <span class="sr-only">Close</span>
      <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
        <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
      </svg>
    </button>
  </div>
</div>

There's a lot going on there so let's break it down. First, there's the case statement which handles styling the alert based on status.

The important thing to notice is the yield in the partial. That means with the flash_alert method is called, I can pass it a block that will be passed down into the partial and rendered where the yield appears. I can use this helper method and partial like so:

<%= flash_alert do %>
  <ul>
    <% @job_post.errors.full_messages.each do |message| %>
      <li><%= message %></li>
    <% end %>
  </ul>
<% end %>

The controller can pass down the title and the message if it wants and the view can be specific and render the alert wherever you choose to place it with whatever inner content you want it to have. And that is the first step in creating a a very flexible flash notification system for your application.

Turbo Frame Alerts

When you're interacting with your application through a Turbo Frame, the interactions will generally update the content of the frame itself and not the rest of your view.

You can use what we've already built pretty easily to embed an alert within a turbo frame update by using the same flash_alert method within the view that renders the turbo frame. It might look something like this:

<%= turbo_frame_tag "new_job" do %>
  <%= form_for @job_post do %>
    <%= flash_alert do %>
      <ul>
        <% @job_post.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    <% end %>

    <!-- The Job Post Form -->
  <% end %>
<% end %>

When validation for that form fails, I can re-render the view with the form and the frame and the flash alert will be displayed along with the errors that failed validation. That's basically all there is to it to support flash alerts within a turbo frame.

Turbo Stream Alerts

When I want to update the flash alert from a Turbo Stream response, I can use a turbo stream action to update the content within the empty alert div that the flash_alert renders when there is no flash alert on the page load. Remember return tag.div id: "alert" unless flash[:notice].present? from the flash_alert helper? Well that's what it's doing, giving the turbo stream a place to put an alert if I need.

Here's what the turbo stream response might look like:

<%= turbo_stream.replace "alert" do %>
  <%= flash_alert %>
<% end %>

If needed, I can pass flash_alert a block and embed more information, but otherwise, as long as I've set the flash in the controller with all the metadata the _alert.html.erb partial will take care of rendering out the content from the controller.

HTTP Toasts

The "toast" pattern is a different kind of notice I want to show my users. It's an ephemeral notification that appears ontop of the UI generally in a corner of the browser. It can be dismissed and generally fades out on it's own after a certain amount of time.

Toasts are great for quick notifications or messages that shouldn't persist to or interupt the UI of the application. An example would be just letting the user know that something was created successfully.

What I do with toasts is embed a partial in my application layout that creates a space for toasts to occupy should the flash type be set to a toast.

<%
if flash[:notice].present? && flash[:notice][:type].to_sym == :toast
  case flash[:notice][:status].to_sym
  when :success
    toast_class = "border-green-200 dark:border-green-700"
    icon_class = "text-green-500 bg-green-100  dark:bg-green-800 dark:text-green-200"
    icon_svg =
      '<svg class="w-5 h-5 text-green-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /></svg>'
  when :warning
    toast_class = "border-orange-200 dark:border-orange-700"
    icon_class = "text-orange-500 bg-orange-100 dark:bg-orange-700 dark:text-orange-200"
    icon_svg =
      '<svg class="w-5 h-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" /></svg>'
  when :error
    toast_class = "border-red-200 dark:border-red-700"
    icon_class = "text-red-500 bg-red-100 dark:bg-red-800 dark:text-red-200"
    icon_svg =
      '<svg class="w-5 h-5 text-red-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd" /></svg>'
  when :info
    toast_class = "border-blue-200 dark:border-blue-700"
    icon_class = "text-blue-500 bg-blue-100 dark:bg-blue-800 dark:text-blue-200"
    icon_svg =
      '<svg class="w-5 h-5 text-blue-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /></svg>'
  end
end
%>

<div id="toast">
  <% if flash[:notice].present? && flash[:notice][:type].to_sym == :toast %>
    <div id="toast-warning" class="<%= toast_class %> fixed z-50 flex items-center w-full max-w-xs p-4 text-gray-500 bg-white border-2 rounded-lg shadow-md top-5 right-5 dark:text-gray-400 dark:bg-gray-800" role="alert"
    data-controller="toast">
      <div class="<%= icon_class %> inline-flex items-center justify-center flex-shrink-0 w-8 h-8 rounded-lg">
        <%= icon_svg.html_safe %>
      </div>
      <div class="text-sm font-normal ms-3">
        <div><%= flash[:notice][:title] %></div>
        <div><%= flash[:notice][:message] %></div>
      </div>
      <button type="button" data-action="toast#remove" class="ms-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8 dark:text-gray-500 dark:hover:text-white dark:bg-gray-800 dark:hover:bg-gray-700" data-dismiss-target="#toast-warning" aria-label="Close">
        <span class="sr-only">Close</span>
        <svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 14 14">
          <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"/>
        </svg>
      </button>
    </div>
  <% end %>
</div>

It's not that much different than the _alert.html.erb partial.

In controllers, when I want a toast to be the notification, I can set it as such:

flash[:notice] = {
  type: :toast,
  status: :success,
  title: "Job Post Created."
}

Toasts though need a Stimulus controller to provide the animations and functionality to allow them to appear and disappear. Here's what I've been using.

import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
  static values = ["delay"];

  connect() {
    this.hasDelayValue ? (this.delay = this.delayValue) : (this.delay = 4000);

    this.element.classList.add(
      "translate-y-2",
      "opacity-0",
      "sm:translate-y-0",
      "sm:translate-x-2"
    );
    setTimeout(() => {
      this.element.classList.remove(
        "translate-y-2",
        "opacity-0",
        "sm:translate-y-0",
        "sm:translate-x-2"
      );
      this.element.classList.add(
        "transform",
        "ease-out",
        "transition",
        "duration-300",
        "translate-y-0",
        "opacity-100",
        "sm:translate-x-0"
      );
    }, 100);
    setTimeout(() => {
      this.remove();
    }, this.delay);
  }

  remove() {
    this.element.classList.remove(
      "transform",
      "ease-out",
      "transition",
      "duration-300",
      "translate-y-0",
      "opacity-100",
      "sm:translate-x-0"
    );
    this.element.classList.add("opacity-0");

    this.element.addEventListener(
      "animationend",
      () => {
        this.element.remove();
      },
      { once: true }
    );

    this.element.classList.add("transition", "ease-in", "duration-100", "opacity-0");
  }
}