Toast
An opinionated toast component.
Examples
Types
Click any to spawn a toast.
div(class: "grid gap-4 sm:grid-cols-2", data: {controller: "toast-demo"}) do
[
["default", "Default"],
["success", "Success"],
["info", "Info"],
["warning", "Warning"],
["error", "Error"],
["with_action", "With Action"],
["promise", "Promise"],
["text_only", "Text Only"],
["close_button", "Close Button"],
["close_action", "Close + Action"],
["no_close", "No Close Button"]
].each do |key, label|
div(class: "rounded-md border p-6 flex items-center justify-center min-h-[100px]") do
button(
type: "button",
class: "inline-flex items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent transition-colors cursor-pointer",
data: {action: "click->toast-demo#fire", toast_demo_kind_param: key}
) { "Show #{label} toast" }
end
end
end
About
Trigger toasts from the server with Turbo Streams or from JavaScript via window.RubyUI.toast.*. Mirrors shadcn's Toast (Base UI): a polite live region, toasts that stack, pause while hovered or focused, and swipe away. Press F6 to move focus to the notifications, Tab to reach the newest toast and Escape to dismiss it.
Mount
# In application_layout.rb (Phlex), once globally:
render RubyUI::ToastRegion.new
# Pass flash to render Rails flash on initial load:
render RubyUI::ToastRegion.new(flash: helpers.flash.to_h)
# The region is a popover="manual": a toast fired while a modal
# Dialog/Sheet is open shows above it (it becomes clickable once
# the modal closes, since the page behind a modal is inert).
Position
Use the position prop to change where toasts mount.
Server-pushed (Turbo Stream)
Append a toast to the global region from any controller.
# Option A — custom Turbo Stream action (compact):
render turbo_stream: turbo_stream.action(
:toast,
target: "ruby-ui-toaster",
variant: :success,
title: "Saved",
description: "Project updated."
)
# Option B — append a fully-rendered ToastItem (for Action / Cancel slots):
render turbo_stream: turbo_stream.append("ruby-ui-toaster") {
render RubyUI::ToastItem.new(variant: :success) do
render RubyUI::ToastIcon.new(variant: :success)
div(class: "flex min-w-0 flex-1 flex-col gap-1") do
render RubyUI::ToastTitle.new { "Saved" }
render RubyUI::ToastDescription.new { "Project updated." }
end
render RubyUI::ToastAction.new(label: "Undo", on: "click->projects#undo")
render RubyUI::ToastClose.new
end
}
JavaScript API
Hotwire-friendly: window.RubyUI.toast.* is sugar over a CustomEvent dispatch. Either path works.
// Sugar:
RubyUI.toast.success("Saved", { description: "Project updated." })
RubyUI.toast.error("Boom")
RubyUI.toast.info("Heads up")
RubyUI.toast.warning("Storage almost full")
RubyUI.toast.loading("Working...")
RubyUI.toast("Saved", { priority: "high" }) // announced right away
RubyUI.toast.dismiss(id) // no-arg: dismiss all (alias: close)
RubyUI.toast.promise(p, { loading, success, error })
// Equivalent CustomEvent (any source can dispatch this):
window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
detail: { variant: "success", title: "Saved", description: "..." }
}))
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Toast
Manual installation
1
Add RubyUI::Toast to app/components/ruby_ui/toast/toast.rb
# frozen_string_literal: true
module RubyUI
module Toast
FLASH_VARIANTS = {
"notice" => :info,
"alert" => :warning,
"success" => :success,
"error" => :error,
"warning" => :warning,
"info" => :info
}.freeze
def self.flash_variant(key)
FLASH_VARIANTS[key.to_s] || :default
end
end
end
2
Add RubyUI::ToastAction to app/components/ruby_ui/toast/toast_action.rb
# frozen_string_literal: true
module RubyUI
# Primary action: shadcn's ToastAction (Button outline, size sm). Clicking it
# also closes the toast (registered divergence: Base UI leaves that to the
# handler).
class ToastAction < Base
def initialize(label:, on: nil, **attrs)
@label = label
@on = on
super(**attrs)
end
def view_template
render RubyUI::Button.new(variant: :outline, size: :sm, **button_attrs) { @label }
end
private
# slot! makes Button replace its own data-slot="button" instead of joining
# it. Added after Base's mix, which would strip the bang.
def button_attrs
attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-action"))
end
def default_attrs
actions = [@on, "click->ruby-ui--toast#close"].compact
{data: {action: actions.join(" ")}, class: "shrink-0"}
end
end
end
3
Add RubyUI::ToastCancel to app/components/ruby_ui/toast/toast_cancel.rb
# frozen_string_literal: true
module RubyUI
# Secondary dismiss button (a RubyUI extra; shadcn's Toast has no cancel).
class ToastCancel < Base
def initialize(label:, **attrs)
@label = label
super(**attrs)
end
def view_template
render RubyUI::Button.new(variant: :ghost, size: :sm, **button_attrs) { @label }
end
private
# slot! makes Button replace its own data-slot="button" instead of joining
# it. Added after Base's mix, which would strip the bang.
def button_attrs
attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-cancel"))
end
def default_attrs
{data: {action: "click->ruby-ui--toast#dismiss"}, class: "shrink-0"}
end
end
end
4
Add RubyUI::ToastClose to app/components/ruby_ui/toast/toast_close.rb
# frozen_string_literal: true
module RubyUI
# shadcn's ToastClose: a ghost icon-sm Button in the toast row, with a
# larger hit area (after:-inset-2).
class ToastClose < Base
def view_template
render RubyUI::Button.new(variant: :ghost, size: :icon_sm, **button_attrs) do
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true"
) do |s|
s.path(d: "M18 6 6 18")
s.path(d: "m6 6 12 12")
end
end
end
private
# slot! makes Button replace its own data-slot="button" instead of joining
# it. Added after Base's mix, which would strip the bang.
def button_attrs
attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "toast-close"))
end
def default_attrs
{
aria_label: "Close toast",
data: {action: "click->ruby-ui--toast#dismiss"},
class: "relative shrink-0 text-muted-foreground after:absolute after:-inset-2 after:content-[''] hover:text-foreground"
}
end
end
end
5
Add RubyUI::ToastDescription to app/components/ruby_ui/toast/toast_description.rb
# frozen_string_literal: true
module RubyUI
# Secondary text; the toast is described by it (aria-describedby, wired by
# the toast controller). Base UI renders a <p>.
class ToastDescription < Base
def view_template(&)
p(**attrs, &)
end
private
def default_attrs
{data: {slot: "toast-description"}, class: "text-sm text-muted-foreground"}
end
end
end
6
Add RubyUI::ToastDocs to app/components/ruby_ui/toast/toast_docs.rb
# frozen_string_literal: true
module RubyUI
class ToastDocs < Phlex::HTML
def view_template
div(class: "space-y-4") do
h2 { "Toast" }
p { "Hotwire-native sonner port. Mount once; trigger via Turbo Stream or window.RubyUI.toast." }
end
end
end
end
7
Add RubyUI::ToastIcon to app/components/ruby_ui/toast/toast_icon.rb
# frozen_string_literal: true
module RubyUI
class ToastIcon < Base
def initialize(variant: nil, **attrs)
@variant = variant&.to_sym
super(**attrs)
end
def view_template
return unless renderable?
span(**attrs) do
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "16",
height: "16",
viewbox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: svg_classes,
aria_hidden: "true"
) { |s| paths(s) }
end
end
private
def renderable?
%i[success error warning info loading].include?(@variant)
end
def svg_classes
case @variant
when :loading then "size-4 animate-spin"
when :error then "size-4 text-destructive"
else "size-4"
end
end
def paths(s)
case @variant
when :success
s.circle(cx: "12", cy: "12", r: "10")
s.path(d: "m9 12 2 2 4-4")
when :error
s.path(d: "M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z")
s.path(d: "m15 9-6 6")
s.path(d: "m9 9 6 6")
when :warning
s.path(d: "m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3")
s.path(d: "M12 9v4")
s.path(d: "M12 17h.01")
when :info
s.circle(cx: "12", cy: "12", r: "10")
s.path(d: "M12 16v-4")
s.path(d: "M12 8h.01")
when :loading
s.path(d: "M21 12a9 9 0 1 1-6.219-8.56")
end
end
def default_attrs
{data: {slot: "toast-icon"}, class: "shrink-0 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"}
end
end
end
8
Add RubyUI::ToastItem to app/components/ruby_ui/toast/toast_item.rb
# frozen_string_literal: true
module RubyUI
# One toast. Mirrors shadcn's Base UI Toast (role=dialog, aria-modal=false,
# named by its title and described by its description) and wraps the block
# in Base UI's ToastContent row.
#
# on_dismiss / on_remove take Stimulus action descriptors ("undo#commit")
# bound to the toast's own ruby-ui:toast:close / ruby-ui:toast:remove events,
# so the named controller must be on the toast or an ancestor.
class ToastItem < Base
PRIORITIES = %i[low high].freeze
REMOVED_KWARGS = %i[invert on_auto_close].freeze
def initialize(
variant: :default,
id: nil,
duration: nil,
dismissible: true,
priority: :low,
on_dismiss: nil,
on_remove: nil,
**attrs
)
removed = attrs.keys & REMOVED_KWARGS
unless removed.empty?
raise ArgumentError, "RubyUI::ToastItem: #{removed.map { |k| "#{k}:" }.join(", ")} " \
"#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog."
end
@variant = variant.to_sym
@id = id
@duration = duration
@dismissible = dismissible
@priority = priority.to_sym
raise ArgumentError, "RubyUI::ToastItem: priority must be :low or :high" unless PRIORITIES.include?(@priority)
@on_dismiss = on_dismiss
@on_remove = on_remove
super(**attrs)
end
def view_template(&)
div(**attrs) do
div(data: {slot: "toast-content"}, class: content_classes, &)
end
end
private
def default_attrs
actions = []
actions << "ruby-ui:toast:close->#{@on_dismiss}" if @on_dismiss
actions << "ruby-ui:toast:remove->#{@on_remove}" if @on_remove
data = {
slot: "toast",
type: @variant.to_s,
variant: @variant.to_s,
priority: @priority.to_s,
state: "pending",
swipe: "none",
controller: "ruby-ui--toast",
ruby_ui__toaster_target: "toast",
ruby_ui__toast_dismissible_value: @dismissible.to_s
}
data[:action] = actions.join(" ") unless actions.empty?
data[:ruby_ui__toast_duration_value] = @duration.to_s if @duration
a = {
role: (@priority == :high) ? "alertdialog" : "dialog",
aria_modal: "false",
tabindex: "0",
data: data,
class: item_classes
}
a[:id] = @id if @id
a
end
# Base UI + vega toast root. The stacking transforms, heights and z-index
# come from the toaster controller (inline styles), not CSS variables.
def item_classes
<<~CLASSES.tr("\n", " ").squeeze(" ").strip
group/toast pointer-events-auto absolute inset-x-0 w-full
rounded-2xl border bg-popover text-popover-foreground shadow-lg
will-change-transform outline-none select-none
focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50
transition-[transform,opacity,height] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]
motion-reduce:transition-none
data-[swipe=move]:transition-none
data-[state=pending]:[transform:translateY(150%)]
in-data-[position^=top]:data-[state=pending]:[transform:translateY(-150%)]
data-[limited]:opacity-0 data-[limited]:pointer-events-none
after:absolute after:top-full after:left-0 after:h-[13px] after:w-full after:content-['']
before:absolute before:bottom-full before:left-0 before:h-[13px] before:w-full before:content-['']
CLASSES
end
def content_classes
"flex h-full items-center gap-3 overflow-hidden p-4 transition-opacity duration-250 " \
"ease-[cubic-bezier(0.22,1,0.36,1)] group-data-[behind]/toast:opacity-0"
end
end
end
9
Add RubyUI::ToastRegion to app/components/ruby_ui/toast/toast_region.rb
# frozen_string_literal: true
module RubyUI
# The toast viewport: a fixed, labelled live region that hosts the toasts.
# Mount it once per page (layout). Mirrors shadcn's Base UI Toaster
# (toast-viewport); positions other than bottom-right are a RubyUI extra.
#
# The region is a popover="manual" so each new toast can lift it into the
# top layer, above a RubyUI Dialog/Sheet opened with showModal().
class ToastRegion < Base
SKELETON_VARIANTS = %i[default success error warning info loading].freeze
POSITIONS = %i[top_left top_center top_right bottom_left bottom_center bottom_right].freeze
SWIPE_DIRECTIONS = %i[up down left right].freeze
REMOVED_KWARGS = %i[offset theme rich_colors dir].freeze
def initialize(
position: :bottom_right,
expand: false,
max: 3,
duration: 5000,
gap: 12,
close_button: true,
hotkey: %w[F6],
swipe_directions: nil,
flash: nil,
**attrs
)
removed = attrs.keys & REMOVED_KWARGS
unless removed.empty?
raise ArgumentError, "RubyUI::ToastRegion: #{removed.map { |k| "#{k}:" }.join(", ")} " \
"#{(removed.size == 1) ? "was" : "were"} removed (it never had an effect). See the Toast changelog."
end
@position = position.to_sym
raise ArgumentError, "RubyUI::ToastRegion: unknown position #{position.inspect}" unless POSITIONS.include?(@position)
@expand = expand
@max = max
@duration = duration
@gap = gap
@close_button = close_button
@hotkey = hotkey
@swipe_directions = swipe_directions && Array(swipe_directions).map(&:to_sym)
unknown = Array(@swipe_directions) - SWIPE_DIRECTIONS
raise ArgumentError, "RubyUI::ToastRegion: unknown swipe direction(s) #{unknown.inspect}" unless unknown.empty?
@flash = flash
super(**attrs)
end
def view_template(&block)
div(**attrs) do
div(id: "ruby-ui-toaster", class: "relative") do
render_flash if @flash
yield(self) if block
end
SKELETON_VARIANTS.each { |v| skeleton(v) }
slot_template("actionTpl") { render RubyUI::ToastAction.new(label: "") }
slot_template("cancelTpl") { render RubyUI::ToastCancel.new(label: "") }
slot_template("closeTpl") { render RubyUI::ToastClose.new }
end
end
private
def render_flash
@flash.each do |key, message|
next if message.nil? || message.to_s.empty?
variant = RubyUI::Toast.flash_variant(key)
render RubyUI::ToastItem.new(variant: variant, id: "flash-#{key}") do
render RubyUI::ToastIcon.new(variant: variant)
div(class: "flex min-w-0 flex-1 flex-col gap-1") do
render RubyUI::ToastTitle.new { message.to_s }
end
render RubyUI::ToastClose.new if @close_button
end
end
end
def skeleton(variant)
template(data: {ruby_ui__toaster_target: "skeleton", variant: variant.to_s}) do
render RubyUI::ToastItem.new(variant: variant) do
render RubyUI::ToastIcon.new(variant: variant)
div(class: "flex min-w-0 flex-1 flex-col gap-1") do
render RubyUI::ToastTitle.new
render RubyUI::ToastDescription.new
end
render RubyUI::ToastClose.new if @close_button
end
end
end
def slot_template(target_name, &)
template(data: {ruby_ui__toaster_target: target_name}, &)
end
def default_attrs
position = @position.to_s.tr("_", "-")
data = {
slot: "toast-viewport",
controller: "ruby-ui--toaster",
turbo_permanent: "",
position: position,
ruby_ui__toaster_position_value: position,
ruby_ui__toaster_expand_value: @expand.to_s,
ruby_ui__toaster_max_value: @max.to_s,
ruby_ui__toaster_duration_value: @duration.to_s,
ruby_ui__toaster_gap_value: @gap.to_s,
ruby_ui__toaster_close_button_value: @close_button.to_s,
ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+")
}
# "none" disables swipe; leaving it unset derives it from the position.
if @swipe_directions
data[:ruby_ui__toaster_swipe_directions_value] = @swipe_directions.empty? ? "none" : @swipe_directions.join(" ")
end
{
id: "ruby-ui-toaster-region",
role: "region",
aria_label: "Notifications",
aria_live: "polite",
aria_relevant: "additions text",
aria_atomic: "false",
tabindex: "-1",
popover: "manual",
data: data,
class: region_classes
}
end
# Base UI's toast-viewport classes (bottom-right), plus resets for the
# popover UA styles (inset: 0, margin: auto, border, padding, background)
# and the other five positions.
def region_classes
<<~CLASSES.tr("\n", " ").squeeze(" ").strip
pointer-events-none fixed z-50 m-0 h-auto w-auto max-w-sm overflow-visible
border-0 bg-transparent p-0 text-inherit outline-none
inset-x-4 mx-auto sm:mx-0 sm:w-full
data-[position^=bottom]:top-auto data-[position^=bottom]:bottom-4
data-[position^=top]:top-4 data-[position^=top]:bottom-auto
data-[position$=right]:sm:right-4 data-[position$=right]:sm:left-auto
data-[position$=left]:sm:left-4 data-[position$=left]:sm:right-auto
data-[position$=center]:sm:left-1/2 data-[position$=center]:sm:right-auto data-[position$=center]:sm:-translate-x-1/2
CLASSES
end
end
end
10
Add RubyUI::ToastTitle to app/components/ruby_ui/toast/toast_title.rb
# frozen_string_literal: true
module RubyUI
# The toast's heading; the toast is named by it (aria-labelledby, wired by
# the toast controller). Base UI renders an <h2>.
class ToastTitle < Base
def view_template(&)
h2(**attrs, &)
end
private
def default_attrs
{data: {slot: "toast-title"}, class: "text-sm font-medium"}
end
end
end
11
Add toast_controller.js to app/javascript/controllers/ruby_ui/toast_controller.js
import { Controller } from "@hotwired/stimulus"
const SWIPE_THRESHOLD = 40
const DEFAULT_DURATION = 5000
const DIRECTIONS = ["up", "down", "left", "right"]
let uid = 0
// Connects to data-controller="ruby-ui--toast"
//
// One toast: its timer, swipe-to-dismiss and exit. The toaster controller
// (region) owns stacking, limits and pausing; it talks to toasts through
// ruby-ui:toast:* events dispatched on the toast element.
export default class extends Controller {
static values = {
duration: Number,
dismissible: { type: Boolean, default: true },
swipeDirections: String,
}
connect() {
this._timer = null
this._startedAt = 0
this._remaining = 0
this._holds = new Set()
this._swipe = null
this._wireLabels()
this._onPointerDown = this._onPointerDown.bind(this)
this._onPointerMove = this._onPointerMove.bind(this)
this._onPointerUp = this._onPointerUp.bind(this)
this._onForceDismiss = (e) => { e.stopPropagation(); this._close("dismiss") }
this._onRestart = () => this._restart()
this._onHold = (e) => this._setHold(e.detail.reason, e.detail.on)
this._onKeyDown = (e) => {
if (e.key === "Escape" && this.element.contains(document.activeElement)) this.dismiss(e)
}
this.element.addEventListener("pointerdown", this._onPointerDown)
this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
this.element.addEventListener("ruby-ui:toast:restart", this._onRestart)
this.element.addEventListener("ruby-ui:toast:hold", this._onHold)
this.element.addEventListener("keydown", this._onKeyDown)
if (this.element.dataset.state === "pending") {
// Two frames: the toaster paints the start position (off-screen) first,
// so the move to the stack position transitions.
this._enterFrame = requestAnimationFrame(() => {
this._enterFrame = requestAnimationFrame(() => {
this._enterFrame = null
this.element.dataset.state = "open"
this.element.dispatchEvent(new CustomEvent("ruby-ui:toast:opened", { bubbles: true }))
})
})
}
this._start()
}
disconnect() {
if (this._enterFrame) cancelAnimationFrame(this._enterFrame)
this._endSwipe({ resume: false })
this._clearTimer()
this.element.removeEventListener("pointerdown", this._onPointerDown)
this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss)
this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart)
this.element.removeEventListener("ruby-ui:toast:hold", this._onHold)
this.element.removeEventListener("keydown", this._onKeyDown)
}
// X, Cancel and swipe: respect dismissible.
dismiss(e) {
e?.preventDefault()
if (!this.dismissibleValue) return
this._close("dismiss")
}
// The action button always closes the toast.
close(e) {
e?.preventDefault()
this._close("dismiss")
}
// aria-labelledby / aria-describedby, as Base UI's Toast.Root does. A
// dialog needs a name: without a title, the description names it, else a
// generic label. Labels the author set are left alone.
_wireLabels() {
const el = this.element
const filled = (node) => node && node.textContent.trim() !== ""
const title = el.querySelector('[data-slot="toast-title"]')
const description = el.querySelector('[data-slot="toast-description"]')
const base = el.id || `ruby-ui-toast-${++uid}`
const named = el.hasAttribute("aria-labelledby") || el.hasAttribute("aria-label")
if (filled(title)) title.id ||= `${base}-title`
if (filled(description)) description.id ||= `${base}-description`
if (!named) {
if (filled(title)) el.setAttribute("aria-labelledby", title.id)
else if (filled(description)) el.setAttribute("aria-labelledby", description.id)
else el.setAttribute("aria-label", "Notification")
}
const describedByTitle = filled(title) || named
if (filled(description) && describedByTitle && !el.hasAttribute("aria-describedby")) {
el.setAttribute("aria-describedby", description.id)
}
}
_duration() {
if (this.hasDurationValue) return this.durationValue
const region = this.element.closest('[data-controller~="ruby-ui--toaster"]')
const inherited = Number(region?.getAttribute("data-ruby-ui--toaster-duration-value"))
return Number.isNaN(inherited) || region === null ? DEFAULT_DURATION : inherited
}
// 0, Infinity and non-numbers mean "stays until dismissed".
_start() {
this._clearTimer()
const duration = this._duration()
if (!Number.isFinite(duration) || duration <= 0) { this._remaining = Infinity; return }
this._remaining = duration
const region = this.element.closest('[data-controller~="ruby-ui--toaster"]')
if (region?.dataset.paused) this._holds.add("region")
if (this.element.hasAttribute("data-limited")) this._holds.add("limited")
if (this._holds.size === 0) this._run()
}
_restart() {
this._start()
}
_run() {
if (!Number.isFinite(this._remaining)) return
if (this._remaining <= 0) return this._close("auto")
this._startedAt = performance.now()
this._timer = setTimeout(() => this._close("auto"), this._remaining)
}
_setHold(reason, on) {
const wasHeld = this._holds.size > 0
if (on) this._holds.add(reason)
else this._holds.delete(reason)
const held = this._holds.size > 0
if (held && !wasHeld && this._timer) {
this._clearTimer()
this._remaining -= performance.now() - this._startedAt
} else if (!held && wasHeld && this.element.dataset.state !== "closing") {
this._run()
}
}
_clearTimer() {
if (this._timer) clearTimeout(this._timer)
this._timer = null
}
_close(reason, exitTransform = null) {
if (this.element.dataset.state === "closing") return
const el = this.element
const detail = { id: el.id, reason }
el.dataset.state = "closing"
this._clearTimer()
this._endSwipe()
el.removeAttribute("data-behind")
el.dispatchEvent(new CustomEvent("ruby-ui:toast:close", { bubbles: true, detail }))
el.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail }))
const top = el.closest("[data-position]")?.dataset.position?.startsWith("top")
el.style.transform = exitTransform || `translateY(${top ? "-150%" : "150%"})`
const transforming = () => el.getAnimations().some((a) => a.transitionProperty === "transform")
let removed = false
const remove = () => {
if (removed) return
removed = true
el.removeEventListener("transitionend", onEnd)
el.removeEventListener("transitioncancel", onEnd)
el.dispatchEvent(new CustomEvent("ruby-ui:toast:remove", { bubbles: true, detail }))
el.remove()
}
// A cancelled enter transition also fires here; wait for the exit one.
const onEnd = (e) => { if (e.target === el && e.propertyName === "transform" && !transforming()) remove() }
el.addEventListener("transitionend", onEnd)
el.addEventListener("transitioncancel", onEnd)
// Flush styles so the exit transition starts now; with none running
// (reduced motion, same transform) there is nothing to wait for.
void el.offsetWidth
if (document.hidden || !transforming()) remove()
}
_directions() {
const own = this.swipeDirectionsValue
const region = this.element.closest('[data-controller~="ruby-ui--toaster"]')
const list = own || region?.dataset.swipeDirections || "down right"
if (list === "none") return []
return list.split(/\s+/).filter((d) => DIRECTIONS.includes(d))
}
_onPointerDown(e) {
if (!this.dismissibleValue || e.button !== 0) return
if (e.target.closest("button, a, input, select, textarea")) return
const directions = this._directions()
if (directions.length === 0) return
try { this.element.setPointerCapture(e.pointerId) } catch {}
this._swipe = { x: e.clientX, y: e.clientY, startedAt: performance.now(), base: this.element.style.transform, directions, dx: 0, dy: 0 }
this._setHold("swipe", true)
this.element.addEventListener("pointermove", this._onPointerMove)
this.element.addEventListener("pointerup", this._onPointerUp)
this.element.addEventListener("pointercancel", this._onPointerUp)
}
// Free movement along allowed directions, resistance along the others.
_onPointerMove(e) {
const s = this._swipe
if (!s) return
const rawX = e.clientX - s.x
const rawY = e.clientY - s.y
const allowedX = (rawX > 0 && s.directions.includes("right")) || (rawX < 0 && s.directions.includes("left"))
const allowedY = (rawY > 0 && s.directions.includes("down")) || (rawY < 0 && s.directions.includes("up"))
s.dx = allowedX ? rawX : Math.sign(rawX) * Math.sqrt(Math.abs(rawX))
s.dy = allowedY ? rawY : Math.sign(rawY) * Math.sqrt(Math.abs(rawY))
this.element.dataset.swipe = "move"
this.element.style.transform = `translateX(${s.dx}px) translateY(${s.dy}px) ${s.base}`
}
_onPointerUp() {
const s = this._swipe
if (!s) return
const elapsed = Math.max(performance.now() - s.startedAt, 1)
const exits = {
right: { distance: s.dx, transform: `translateX(calc(${s.dx}px + 150%)) translateY(${s.dy}px) ${s.base}` },
left: { distance: -s.dx, transform: `translateX(calc(${s.dx}px - 150%)) translateY(${s.dy}px) ${s.base}` },
down: { distance: s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px + 150%)) ${s.base}` },
up: { distance: -s.dy, transform: `translateX(${s.dx}px) translateY(calc(${s.dy}px - 150%)) ${s.base}` },
}
const direction = s.directions.find((d) => {
const distance = exits[d].distance
return distance > SWIPE_THRESHOLD || (distance > 10 && distance / elapsed > 0.5)
})
const base = s.base
this._endSwipe()
if (direction) {
this.element.dataset.swipe = "end"
this.element.dataset.swipeDirection = direction
this._close("dismiss", exits[direction].transform)
} else {
this.element.dataset.swipe = "cancel"
this.element.style.transform = base
}
}
_endSwipe({ resume = true } = {}) {
if (!this._swipe) return
this._swipe = null
this.element.removeEventListener("pointermove", this._onPointerMove)
this.element.removeEventListener("pointerup", this._onPointerUp)
this.element.removeEventListener("pointercancel", this._onPointerUp)
if (resume) this._setHold("swipe", false)
else this._holds.delete("swipe")
}
}
12
Add toaster_controller.js to app/javascript/controllers/ruby_ui/toaster_controller.js
import { Controller } from "@hotwired/stimulus"
const VARIANTS = ["default", "success", "error", "warning", "info", "loading"]
const PEEK = 12
const SCALE_STEP = 0.1
let streamActionRegistered = false
function registerStreamAction() {
if (streamActionRegistered) return
if (typeof window === "undefined") return
const Turbo = window.Turbo
if (!Turbo?.StreamActions) return
Turbo.StreamActions.toast = function () {
const detail = {}
for (const attr of this.attributes) {
if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue
detail[attr.name] = attr.value
}
if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration)
if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false"
window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail }))
}
streamActionRegistered = true
}
// Connects to data-controller="ruby-ui--toaster"
//
// The toast viewport (shadcn's Base UI Toaster): stacks the toasts, limits how
// many show, pauses every timer while the region is hovered or focused (or the
// window is blurred), focuses the region on the hotkey (F6), and spawns
// toasts from window.RubyUI.toast / the ruby-ui:toast event / Turbo Streams.
export default class extends Controller {
static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
static values = {
position: { type: String, default: "bottom-right" },
expand: { type: Boolean, default: false },
max: { type: Number, default: 3 },
duration: { type: Number, default: 5000 },
gap: { type: Number, default: 12 },
closeButton: { type: Boolean, default: true },
hotkey: { type: String, default: "F6" },
swipeDirections: String,
}
initialize() {
this._hovered = false
this._holds = new Set()
this._heights = new WeakMap()
this._resizeObservers = new WeakMap()
this._announcers = new Map() // toast element -> its hidden role=alert
}
connect() {
// Turbo moves this permanent region between pages: start from a clean
// pause state instead of whatever the last page left behind.
this._hovered = this.element.matches(":hover")
this._holds.clear()
delete this.element.dataset.paused
this._listEl = this.element.querySelector("#ruby-ui-toaster") || this.element
this._registerGlobalApi()
registerStreamAction()
this._showPopover()
this._onPointerEnter = () => { this._hovered = true; this._setHold("hover", true); this._reflow() }
this._onPointerLeave = () => { this._hovered = false; this._setHold("hover", false); this._reflow() }
this._onFocusIn = () => { this._setHold("focus", true); this._reflow() }
this._onFocusOut = (e) => {
if (this.element.contains(e.relatedTarget)) return
this._setHold("focus", false)
this._reflow()
}
this._onWindowBlur = (e) => { if (e.target === window) this._setHold("window", true) }
this._onWindowFocus = (e) => { if (e.target === window) this._setHold("window", false) }
this._onWindowToast = (e) => this._spawn(e.detail || {})
this._onWindowDismissAll = () => this._dismissById(null)
this._onToastChange = () => this._reflow()
this._onToastRemove = (e) => this._handOffFocus(e.target)
this._onRegionKeyDown = (e) => {
// Base UI: Tab from the focused viewport goes to the front toast.
if (e.key !== "Tab" || e.shiftKey || document.activeElement !== this.element) return
const front = this._frontToast()
if (!front) return
e.preventDefault()
front.focus()
}
this._onKey = this._onKey.bind(this)
window.addEventListener("ruby-ui:toast", this._onWindowToast)
window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
window.addEventListener("blur", this._onWindowBlur, true)
window.addEventListener("focus", this._onWindowFocus, true)
this.element.addEventListener("pointerenter", this._onPointerEnter)
this.element.addEventListener("pointerleave", this._onPointerLeave)
this.element.addEventListener("focusin", this._onFocusIn)
this.element.addEventListener("focusout", this._onFocusOut)
this.element.addEventListener("ruby-ui:toast:opened", this._onToastChange)
this.element.addEventListener("ruby-ui:toast:close", this._onToastChange)
this.element.addEventListener("ruby-ui:toast:remove", this._onToastRemove)
this.element.addEventListener("keydown", this._onRegionKeyDown)
document.addEventListener("keydown", this._onKey)
if (this._hovered) this._setHold("hover", true)
if (this.element.contains(document.activeElement)) this._setHold("focus", true)
this._syncSwipeDirections()
this._reflow()
}
disconnect() {
window.removeEventListener("ruby-ui:toast", this._onWindowToast)
window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
window.removeEventListener("blur", this._onWindowBlur, true)
window.removeEventListener("focus", this._onWindowFocus, true)
this.element.removeEventListener("pointerenter", this._onPointerEnter)
this.element.removeEventListener("pointerleave", this._onPointerLeave)
this.element.removeEventListener("focusin", this._onFocusIn)
this.element.removeEventListener("focusout", this._onFocusOut)
this.element.removeEventListener("ruby-ui:toast:opened", this._onToastChange)
this.element.removeEventListener("ruby-ui:toast:close", this._onToastChange)
this.element.removeEventListener("ruby-ui:toast:remove", this._onToastRemove)
this.element.removeEventListener("keydown", this._onRegionKeyDown)
document.removeEventListener("keydown", this._onKey)
this._holds.clear()
delete this.element.dataset.paused
this._announcers.forEach((node) => node.remove())
this._announcers.clear()
}
positionValueChanged(value) {
this.element.setAttribute("data-position", value)
this._syncSwipeDirections()
this._reflow()
}
expandValueChanged() {
this._reflow()
}
swipeDirectionsValueChanged() {
this._syncSwipeDirections()
}
toastTargetConnected(el) {
this._resizeObservers.get(el)?.disconnect()
if (typeof ResizeObserver !== "undefined") {
const ro = new ResizeObserver(() => {
// Only a toast at its own height tells us its natural height.
if (el.style.height !== "") return
if (this._heights.get(el) === el.offsetHeight) return
this._heights.set(el, el.offsetHeight)
this._reflow()
})
ro.observe(el)
this._resizeObservers.set(el, ro)
}
this._heights.set(el, el.offsetHeight)
if (this._holds.size > 0) el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "region", on: true } }))
if (el.dataset.priority === "high") this._announce(el)
this._raiseAboveModals()
this._reflow()
}
toastTargetDisconnected(el) {
this._resizeObservers.get(el)?.disconnect()
this._resizeObservers.delete(el)
this._removeAnnouncer(el)
this._reflow()
}
// ---- top layer -------------------------------------------------------
_showPopover() {
if (typeof this.element.showPopover !== "function") return
try {
if (!this.element.matches(":popover-open")) this.element.showPopover()
} catch {}
}
// A modal <dialog> (RubyUI Dialog, AlertDialog, Sheet) sits in the top
// layer; showing the region again puts it above that dialog so new toasts
// are visible. (The page behind a modal stays inert, so they are not
// clickable until it closes.)
_raiseAboveModals() {
if (typeof this.element.showPopover !== "function") return
if (!document.querySelector("dialog:modal")) return
if (this.element.contains(document.activeElement)) return
try {
if (this.element.matches(":popover-open")) this.element.hidePopover()
this.element.showPopover()
} catch {}
}
// ---- pausing ---------------------------------------------------------
_setHold(reason, on) {
const wasHeld = this._holds.size > 0
if (on) this._holds.add(reason)
else this._holds.delete(reason)
const held = this._holds.size > 0
if (held) this.element.dataset.paused = [...this._holds].join(" ")
else delete this.element.dataset.paused
if (reason === "focus") this._syncHighPriorityVisibility(on)
if (held === wasHeld) return
this.toastTargets.forEach((el) =>
el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "region", on: held } }))
)
}
// A focused toast leaving the DOM fires no focusout: move focus to the next
// front toast, else back to where it was before F6, and drop the focus hold.
_handOffFocus(toast) {
if (!toast.contains(document.activeElement)) return
const next = this._frontToast(toast)
if (next) return next.focus()
const previous = this._previousFocus
this._previousFocus = null
if (previous?.isConnected) previous.focus({ preventScroll: true })
else document.activeElement?.blur()
this._setHold("focus", false)
this._reflow()
}
_frontToast(except = null) {
return this.toastTargets
.filter((el) => el !== except && el.dataset.state !== "closing" && !el.hasAttribute("data-limited"))
.at(-1)
}
// ---- high priority (Base UI): role=alertdialog, announced through a
// visually hidden role=alert; the toast itself is hidden from AT until
// focus enters the region, so it isn't read twice.
_announce(el) {
if (this._holds.has("focus")) return
const title = el.querySelector('[data-slot="toast-title"]')?.textContent || ""
const description = el.querySelector('[data-slot="toast-description"]')?.textContent || ""
const node = document.createElement("div")
node.setAttribute("role", "alert")
node.setAttribute("aria-atomic", "true")
node.className = "sr-only"
node.dataset.toastAnnouncement = ""
node.textContent = [title, description].filter(Boolean).join(". ")
this.element.insertAdjacentElement("afterend", node)
this._announcers.get(el)?.remove()
this._announcers.set(el, node)
el.setAttribute("aria-hidden", "true")
}
_removeAnnouncer(el) {
this._announcers.get(el)?.remove()
this._announcers.delete(el)
}
// With focus inside, the toasts themselves are read: drop the hidden
// announcements (so nothing is read twice) and expose the toasts.
_syncHighPriorityVisibility(focused) {
if (focused) {
this._announcers.forEach((node) => node.remove())
this._announcers.clear()
}
this.toastTargets
.filter((el) => el.dataset.priority === "high")
.forEach((el) => (focused ? el.removeAttribute("aria-hidden") : el.setAttribute("aria-hidden", "true")))
}
// ---- stacking --------------------------------------------------------
_isTop() {
return this.positionValue.startsWith("top")
}
_syncSwipeDirections() {
let directions = this.swipeDirectionsValue
if (!directions) {
const position = this.positionValue
directions = [
position.startsWith("top") ? "up" : "down",
position.endsWith("right") ? "right" : position.endsWith("left") ? "left" : null,
].filter(Boolean).join(" ")
}
this.element.dataset.swipeDirections = directions
}
_naturalHeight(el) {
return this._heights.get(el) || el.offsetHeight || 64
}
// Base UI's stack: newest in front; collapsed toasts peek 12px and shrink
// 10% per step, at the front toast's height with their content hidden;
// expanded toasts sit `gap` apart. Toasts past `max` stay mounted but hidden
// (data-limited) and come back when a front toast closes.
_reflow() {
if (!this._listEl) return
const top = this._isTop()
const sign = top ? 1 : -1
const expanded = this.expandValue || this._hovered || this._holds.has("focus")
const order = this.toastTargets.filter((el) => el.dataset.state !== "closing").reverse()
const frontHeight = order.length ? this._naturalHeight(order[0]) : 0
let offset = 0
order.forEach((el, i) => {
const limited = i >= this.maxValue
this._setLimited(el, limited)
el.style.zIndex = String(1000 - i)
el.style.transformOrigin = top ? "center top" : "center bottom"
el.style.top = top ? "0" : "auto"
el.style.bottom = top ? "auto" : "0"
if (expanded) {
el.removeAttribute("data-behind")
el.style.height = ""
el.style.transform = el.dataset.state === "pending" ? this._startTransform() : `translateY(${sign * offset}px)`
} else {
const scale = Math.max(0, 1 - i * SCALE_STEP)
const lift = i * PEEK + (1 - scale) * frontHeight
if (i > 0) el.setAttribute("data-behind", "")
else el.removeAttribute("data-behind")
el.style.height = i > 0 ? `${frontHeight}px` : ""
el.style.transform = el.dataset.state === "pending" ? this._startTransform() : `translateY(${sign * lift}px) scale(${scale})`
}
if (el.dataset.state === "pending") void el.offsetWidth // paint the start position before it opens
offset += this._naturalHeight(el) + this.gapValue
})
this.element.dataset.expanded = String(expanded)
}
_startTransform() {
return `translateY(${this._isTop() ? "-150%" : "150%"})`
}
_setLimited(el, limited) {
if (limited === el.hasAttribute("data-limited")) return
if (limited) {
el.setAttribute("data-limited", "")
el.inert = true
} else {
el.removeAttribute("data-limited")
el.inert = false
}
el.dispatchEvent(new CustomEvent("ruby-ui:toast:hold", { detail: { reason: "limited", on: limited } }))
}
// ---- spawning --------------------------------------------------------
_spawn(detail) {
if (!this._listEl) return null
const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
const tpl = this._skeletonFor(variant)
if (!tpl) return null
if (detail.position) this.positionValue = detail.position
const node = tpl.content.firstElementChild.cloneNode(true)
const content = node.querySelector('[data-slot="toast-content"]') || node
node.id = detail.id || `toast-${this._uuid()}`
const duration = detail.duration ?? this.durationValue
node.setAttribute("data-ruby-ui--toast-duration-value", String(duration === Infinity ? 0 : duration))
if (detail.dismissible === false) node.setAttribute("data-ruby-ui--toast-dismissible-value", "false")
if (detail.swipeDirections) {
const list = [].concat(detail.swipeDirections)
node.setAttribute("data-ruby-ui--toast-swipe-directions-value", list.length ? list.join(" ") : "none")
}
if (detail.priority === "high") {
node.dataset.priority = "high"
node.setAttribute("role", "alertdialog")
}
if (detail.className) node.className += ` ${detail.className}`
const titleEl = node.querySelector('[data-slot="toast-title"]')
if (titleEl) titleEl.textContent = detail.title || detail.message || ""
const descEl = node.querySelector('[data-slot="toast-description"]')
if (descEl) {
if (detail.description) descEl.textContent = detail.description
else descEl.remove()
}
let close = node.querySelector('[data-slot="toast-close"]')
if (detail.closeButton === false && close) {
close.remove()
close = null
} else if (detail.closeButton === true && !close && this.hasCloseTplTarget) {
close = this._cloneSlot(this.closeTplTarget)
content.appendChild(close)
}
if (detail.cancel?.label && this.hasCancelTplTarget) {
const btn = this._cloneSlot(this.cancelTplTarget)
btn.textContent = detail.cancel.label
if (detail.cancel.onClick) btn.addEventListener("click", (ev) => detail.cancel.onClick(ev))
content.insertBefore(btn, close)
}
if (detail.action?.label && this.hasActionTplTarget) {
const btn = this._cloneSlot(this.actionTplTarget)
btn.textContent = detail.action.label
if (detail.action.onClick) btn.addEventListener("click", (ev) => detail.action.onClick(ev))
content.insertBefore(btn, close)
}
if (typeof detail.onClose === "function") node.addEventListener("ruby-ui:toast:close", (e) => detail.onClose(e.detail))
if (typeof detail.onRemove === "function") node.addEventListener("ruby-ui:toast:remove", (e) => detail.onRemove(e.detail))
this._listEl.appendChild(node)
return node.id
}
_dismissById(id) {
const targets = id ? [this._listEl?.querySelector(`#${CSS.escape(id)}`)].filter(Boolean) : this.toastTargets
targets.forEach((el) => el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })))
}
_skeletonFor(variant) {
return this.skeletonTargets.find((t) => t.dataset.variant === variant)
}
_cloneSlot(tpl) {
return tpl.content.firstElementChild.cloneNode(true)
}
// Base UI: the hotkey (F6) moves focus to the viewport; Tab then goes to
// the front toast.
_onKey(e) {
if (!e.key) return
const parts = (this.hotkeyValue || "F6").toLowerCase().split("+")
const key = parts.pop()
if (e.key.toLowerCase() !== key) return
if (parts.includes("alt") !== e.altKey) return
if (parts.includes("ctrl") !== e.ctrlKey) return
if (parts.includes("meta") !== e.metaKey) return
if (parts.includes("shift") !== e.shiftKey) return
if (!this._frontToast() || document.activeElement === this.element) return
e.preventDefault()
if (!this.element.contains(document.activeElement)) this._previousFocus = document.activeElement
this.element.focus({ preventScroll: true })
}
_registerGlobalApi() {
const fire = (variant, message, opts = {}) => {
const id = opts.id || `toast-${this._uuid()}`
window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
detail: { ...opts, id, variant, message: opts.title || message }
}))
return id
}
const api = (message, opts) => fire("default", message, opts)
api.success = (m, o) => fire("success", m, o)
api.error = (m, o) => fire("error", m, o)
api.warning = (m, o) => fire("warning", m, o)
api.info = (m, o) => fire("info", m, o)
api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
api.dismiss = (id) => {
if (id) this._dismissById(id)
else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all"))
}
api.close = api.dismiss
api.promise = (p, msgs = {}) => {
const id = fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { duration: 0 })
Promise.resolve(p).then(
(val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
(err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
)
return id
}
window.RubyUI = window.RubyUI || {}
window.RubyUI.toast = api
}
_mutate(id, variant, text) {
const el = this._listEl?.querySelector(`#${CSS.escape(id)}`)
if (!el) return
el.dataset.variant = variant
el.dataset.type = variant
this._swapIcon(el, variant)
const t = el.querySelector('[data-slot="toast-title"]')
if (t && text) t.textContent = text
el.setAttribute("data-ruby-ui--toast-duration-value", String(this.durationValue))
el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true }))
}
_swapIcon(el, variant) {
const iconHost = el.querySelector('[data-slot="toast-icon"]')
if (!iconHost) return
const tpl = this._skeletonFor(variant)
if (!tpl) return
const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="toast-icon"]')
iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : ""
}
_uuid() {
if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID()
return Math.random().toString(36).slice(2) + Date.now().toString(36)
}
}
13
Update the Stimulus controllers manifest file
rake stimulus:manifest:update14
Install required components
Component Toast relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
Toast | Phlex | |
ToastAction | Phlex | |
ToastCancel | Phlex | |
ToastClose | Phlex | |
ToastDescription | Phlex | |
ToastDocs | Phlex | |
ToastIcon | Phlex | |
ToastItem | Phlex | |
ToastRegion | Phlex | |
ToastTitle | Phlex | |
ToastController | Stimulus JS | |
ToasterController | Stimulus JS |
API Reference
ToastRegion (Toaster)
| Prop | Default | Values | Description |
|---|---|---|---|
position | :bottom_right | :top_left | :top_center | :top_right | :bottom_left | :bottom_center | :bottom_right | Where the toaster mounts on the viewport (shadcn's is bottom-right). |
expand | false | Boolean | Always show toasts expanded (no stack peek). |
max | 3 | Integer | Toasts shown at once (Base UI limit). Older ones stay hidden and come back when a front toast closes. |
duration | 5000 | Integer (ms) | Float::INFINITY | Default lifetime per toast (Base UI timeout). 0 or Float::INFINITY keeps toasts until dismissed. |
gap | 12 | Integer (px) | Spacing between toasts when expanded. |
close_button | true | Boolean | Render the X button in every toast. |
hotkey | %w[F6] | Array<String> | Shortcut that moves focus to the notifications region (Tab then reaches the newest toast), e.g. %w[alt t]. |
swipe_directions | (from position) | Array<:up | :down | :left | :right> | Directions a swipe can dismiss in. Defaults to down/right for bottom-right; [] disables swipe. |
flash | nil | Hash | nil | Pass `helpers.flash.to_h` to render Rails flash on initial load. |
ToastItem
| Prop | Default | Values | Description |
|---|---|---|---|
variant | :default | :default | :success | :error | :warning | :info | :loading | Icon and data-type. |
priority | :low | :low | :high | High-priority toasts are announced right away (role=alert) and use role=alertdialog. |
id | nil | String | DOM id; auto-generated when not provided. |
duration | nil | Integer | Float::INFINITY | nil | Override the region default. nil inherits it; 0 or Float::INFINITY stays until dismissed. |
dismissible | true | Boolean | Allow Escape, swipe, the X and Cancel to close it. RubyUI.toast.dismiss() closes it either way. |
on_dismiss | nil | String | Stimulus action ("controller#method") run on ruby-ui:toast:close. The controller must be on the toast or an ancestor. |
on_remove | nil | String | Stimulus action run on ruby-ui:toast:remove, after the exit animation. |
JS API options
Second argument to RubyUI.toast.<variant>(message, options) or ruby-ui:toast CustomEvent detail.
| Prop | Default | Values | Description |
|---|---|---|---|
title | — | String | Headline text. Falls back to the first positional argument. |
description | — | String | Secondary line under the title. |
duration | (region default) | Number | Infinity | ms before auto-dismiss. 0 or Infinity = stays until dismissed. |
priority | "low" | "low" | "high" | "high" is announced right away. |
action | — | { label, onClick } | Action button (outline). Clicking it closes the toast. |
cancel | — | { label, onClick } | Secondary dismiss button (RubyUI extra). |
closeButton | (region default) | Boolean | Show or hide the X on this toast. |
onClose | — | Function | Called when the toast starts closing ({ id, reason }). |
onRemove | — | Function | Called after the exit animation, when the toast leaves the DOM. |
swipeDirections | (region default) | Array<String> | Directions a swipe can dismiss this toast in. |
position | (region default) | String | Moves the region before this toast is added. |
id | (auto) | String | Stable id (used by .dismiss(id) and .promise). |
dismissible | true | Boolean | false turns off Escape, swipe, the X and Cancel for this toast; RubyUI.toast.dismiss() still closes it. |
className | — | String | Extra classes on the toast element. |