Skip to content

Lesson 5 — Toast

Toast notifications are ephemeral messages that appear, persist briefly, and disappear — without interrupting the user’s workflow. They differ from Alert in that they float over the UI, queue automatically, and auto-dismiss.

This also replaces the flash message pattern introduced in Module 6. Flash messages are functional but have three problems: they tell you the obvious, they stick around until you navigate away, and they push the layout out of shape. Toasts solve all three.

Architecture

Toast requires two parts:

  1. A container — fixed-position, always present in AppLayout
  2. A toast — an individual notification with auto-dismiss

Components::ToastContainer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# app/components/toast_container.rb
class Components::ToastContainer < Components::Base
  def view_template
    div(
      id:    "toast-container",
      class: "fixed bottom-4 right-4 z-50 flex flex-col gap-2 " \
             "pointer-events-none",
      data:  { controller: "toast-container" },
      aria:  { live: "polite", atomic: false }
    )
  end
end

The aria-live="polite" attribute makes the container a live region — screen readers announce new toasts as they appear without interrupting the current reading flow.

Add to AppLayout just before the closing body tag:

1
2
3
4
5
6
7
body(class: "bg-surface text-text min-h-screen flex flex-col") do
  render_nav
  render_flash
  main(class: "px-8 py-8 flex-1 w-full") { yield }
  render_footer
  ToastContainer()
end

Components::Toast

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# app/components/toast.rb
class Components::Toast < Components::Base
  VARIANTS = {
    info:    "bg-surface border-border text-text",
    success: "bg-success-bg border-success/30 text-success",
    warning: "bg-warning-bg border-warning/30 text-warning",
    danger:  "bg-danger/10 border-danger/30 text-danger",
  }.freeze

  prop :message,  String
  prop :variant,  Symbol,  default: -> { :info }
  prop :duration, Integer, default: 4   # duration is in seconds

  def view_template
    div(
      role:  "status",
      class: toast_classes,
      data:  { controller: "toast", toast_duration_value: @duration * 1000 }
    ) do
      button(
        type:  "button",
        class: "absolute right-3 top-3 leading-none opacity-70 hover:opacity-100",
        data:  { action: "click->toast#dismiss" }
      ) { "×" }

      div(class: "flex items-start gap-3 pr-6") do
        span(class: "shrink-0") { Icon(name: icon_for(@variant)) }
        span(class: "flex-1 text-sm font-medium pt-0.5") { @message }
      end
    end
  end

  private

  def icon_for(variant)
    case variant
    when :info    then :information_circle
    when :success then :check_circle
    when :warning then :exclamation_triangle
    when :danger  then :x_circle
    end
  end

  def toast_classes
    "pointer-events-auto relative flex w-full max-w-sm rounded-lg border " \
      "shadow-lg px-4 py-3 #{VARIANTS[@variant]}"
  end
end

The Stimulus controller

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// app/javascript/controllers/toast_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { duration: { type: Number, default: 4000 } }

  connect()    { this.scheduleRemoval() }
  disconnect() { clearTimeout(this.removalTimer) }
  dismiss()    { this.remove() }

  scheduleRemoval() {
    if (this.durationValue > 0) {
      this.removalTimer = setTimeout(() => this.remove(), this.durationValue)
    }
  }

  remove() {
    this.element.style.transition = "opacity 200ms ease-out, transform 200ms ease-out"
    this.element.style.opacity    = "0"
    this.element.style.transform  = "translateX(100%)"
    setTimeout(() => this.element.remove(), 200)
  }
}

Triggering toasts from the server

The cleanest way to show a toast after a server action is via a Turbo Stream that appends to the container:

1
2
3
4
5
6
7
8
# app/helpers/toast_helper.rb
module ToastHelper
  def render_toast(message, variant: :info, duration: 4000)
    turbo_stream.append "toast-container" do
      render Components::Toast.new(message:, variant:, duration:)
    end
  end
end

In a controller action:

1
2
3
4
5
6
7
8
def create
  if @board.save
    respond_to do |format|
      format.turbo_stream { render_toast "Board created!", variant: :success }
      format.html         { redirect_to boards_path }
    end
  end
end

The toast appears without a page reload. Include ToastHelper in ApplicationController:

1
2
3
4
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include ToastHelper
end