Toast
Notification toaster + Toast. Sonner-style stacking, group pause-on-hover, swipe-to-dismiss, Turbo-Stream friendly.
Wabi's Toast uses a vanilla JS controller — @zag-js/toast group machine is deferred to v0.6.
Installation
bin/rails g wabi:add toastLayout setup
# In your application layout (once, near end of <body>):
render Components::UI::Toaster.new
Example
Toasts are dispatched from a Rails action via Turbo Stream. Click a button several times to build a stack — toasts collapse into a peek pile, expand when you hover the group (which also pauses auto-dismiss), and can be swiped away.
Info
Something happened that you should know about.
Success
Your changes have been saved.
Destructive
Something went wrong. Please try again.
# In a Rails controller action:
# render turbo_stream: turbo_stream.wabi_toast(
# title: "Saved", description: "Profile updated.", appearance: :success
# )
#
# The demo below uses the wabi--toast-demo Stimulus controller to
# clone a <template> and append it to the Toaster (mimicking the
# Turbo Stream append pattern client-side).
render Components::UI::Toaster.new
Source
app/components/ui/toast.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# A single notification. Render via Turbo Stream into the Toaster container,
# or inline in a Phlex view for static / story examples. Has its own
# `wabi--toast` Stimulus controller that handles auto-dismiss with
# pause-on-hover -- v0.1 skips Zag's `@zag-js/toast` group machinery in
# favor of a self-contained vanilla timer. Cross-toast coordination (max
# visible, advanced stacking) is a v0.2 follow-up.
class Toast < Wabi::Base
variants do
base "pointer-events-auto w-full overflow-hidden rounded-md border border-input p-4 shadow-md " \
"transition-all duration-300 ease-out motion-reduce:transition-none motion-reduce:opacity-100 " \
"data-[state=open]:opacity-100 data-[state=closed]:opacity-0"
variant :appearance, {
info: "bg-background text-foreground",
success: "bg-primary text-primary-foreground",
destructive: "bg-destructive text-destructive-foreground",
}, default: :info
end
def initialize(title:, description: nil, appearance: nil, duration_ms: 5000, **attrs)
@title = title
@description = description
@appearance = appearance
@duration_ms = duration_ms
@attrs = attrs
end
def view_template
user_class = @attrs.delete(:class)
# No per-<li> live-region attrs: announcement comes from the Toaster <ol>
# (the pre-existing aria-live region) when this <li> is appended into it.
li(
"data-state": "open",
data: {
controller: "wabi--toast",
"wabi--toast-duration-ms-value": @duration_ms.to_s,
},
class: merge_class(tokens(appearance: @appearance), user_class)
) do
div(class: "flex items-start justify-between gap-3") do
div(class: "grid gap-1") do
div(class: "text-sm font-semibold") { @title }
div(class: "text-sm opacity-90") { @description } if @description
end
button(
type: "button",
"aria-label": "Dismiss",
data: { action: "click->wabi--toast#dismiss" },
class: "shrink-0 rounded-md p-1 text-current opacity-70 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
) { "×" }
end
end
end
end
end
end
app/components/ui/toaster.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Singleton container for toasts. Render ONCE near the end of <body> in your
# layout. Toasts are appended to this <ol> -- typically via Turbo Stream:
#
# turbo_stream.append "wabi-toaster",
# Components::UI::Toast.new(title: "Saved", appearance: :success)
#
# The list is `pointer-events-none` so the empty toaster doesn't block clicks
# behind it; individual toasts override with `pointer-events-auto`.
class Toaster < Wabi::Base
def initialize(id: "wabi-toaster", placement: :top_right, visible_count: 3, gap: 14, **attrs)
@id = id
@placement = placement
@visible_count = visible_count
@gap = gap
@attrs = attrs
end
PLACEMENT_CLASSES = {
top_left: "top-4 left-4",
top_center: "top-4 left-1/2 -translate-x-1/2",
top_right: "top-4 right-4",
bottom_left: "bottom-4 left-4",
bottom_center: "bottom-4 left-1/2 -translate-x-1/2",
bottom_right: "bottom-4 right-4",
}.freeze
def view_template
user_class = @attrs.delete(:class)
# The <ol> is the containing block for its absolutely-positioned toast
# <li> children: the wabi--toaster controller sets each toast to
# position:absolute and assigns its translateY/scale transform. Without
# JS the <li>s fall back to normal block flow (a plain vertical list),
# so the no-JS experience still works. `w-96`/`h-fit` keep the empty
# container sized correctly; `position: fixed` is itself the containing
# block for the absolute toast children.
ol(
id: @id,
role: "region",
"aria-label": "Notifications",
# The <ol> is the live region: it pre-exists, so toast <li>s appended
# via Turbo Stream are announced. (Per-<li> live-region attrs do NOT
# announce — AT only reacts to mutations inside an existing live region.)
"aria-live": "polite",
"aria-atomic": "false",
data: {
controller: "wabi--toaster",
# @id must be a simple alphanumeric/hyphen string; no CSS escaping is applied.
"wabi--toaster-wabi--toast-outlet": "##{@id} > [data-controller~='wabi--toast']",
"wabi--toaster-placement-value": @placement.to_s,
"wabi--toaster-visible-count-value": @visible_count.to_s,
"wabi--toaster-gap-value": @gap.to_s,
},
class: merge_class(
"fixed z-50 w-96 max-w-[calc(100vw-2rem)] h-fit pointer-events-none list-none p-0 m-0",
PLACEMENT_CLASSES.fetch(@placement),
user_class,
)
)
end
end
end
end
Accessibility
- role="status" + aria-live="polite" + aria-atomic="true" on every toast — appearance is purely visual.
- auto-dismiss after duration_ms (default 5s); hover pauses the timer so users have time to read.
- each toast has a manual close button (×) for keyboard / screen-reader users.
- for urgent / destructive messages pair toasts with an inline error message in those flows, or wait for the @zag-js/toast group machine in v0.6.