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"] ].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.*. Heavily inspired by the original sonner.
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)
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) render RubyUI::ToastTitle.new { "Saved" } render RubyUI::ToastDescription.new { "Project updated." } 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.dismiss(id) // no-arg: dismiss all 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 class ToastAction < Base def initialize(label:, on: nil, **attrs) @label = label @on = on super(**attrs) end def view_template button(**attrs) { @label } end private def default_attrs data = {slot: "action"} data[:action] = @on if @on { type: "button", data: data, class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground text-background border-0 ml-auto hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-ring transition-opacity disabled:pointer-events-none disabled:opacity-50" } end end end
3
Add RubyUI::ToastCancel to app/components/ruby_ui/toast/toast_cancel.rb
# frozen_string_literal: true module RubyUI class ToastCancel < Base def initialize(label:, **attrs) @label = label super(**attrs) end def view_template button(**attrs) { @label } end private def default_attrs { type: "button", data: { slot: "cancel", action: "click->ruby-ui--toast#dismiss" }, class: "inline-flex h-6 shrink-0 cursor-pointer items-center justify-center rounded px-2 text-xs font-medium bg-foreground/10 text-foreground border-0 ml-auto hover:bg-foreground/15 focus:outline-none focus:ring-2 focus:ring-ring transition-colors" } end end end
4
Add RubyUI::ToastClose to app/components/ruby_ui/toast/toast_close.rb
# frozen_string_literal: true module RubyUI class ToastClose < Base def view_template button(**attrs) do svg( xmlns: "http://www.w3.org/2000/svg", width: "14", height: "14", viewbox: "0 0 24 24", fill: "none", stroke: "currentColor", stroke_width: "2", stroke_linecap: "round", stroke_linejoin: "round", class: "size-3.5" ) do |s| s.path(d: "M18 6 6 18") s.path(d: "m6 6 12 12") end span(class: "sr-only") { "Close" } end end private def default_attrs { type: "button", aria_label: "Close toast", data: { slot: "close", action: "click->ruby-ui--toast#dismiss" }, class: "absolute right-2 top-2 size-6 cursor-pointer rounded-md text-foreground/60 p-0 flex items-center justify-center transition-colors hover:bg-muted hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring" } end end end
5
Add RubyUI::ToastDescription to app/components/ruby_ui/toast/toast_description.rb
# frozen_string_literal: true module RubyUI class ToastDescription < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: {slot: "description"}, class: "font-normal leading-[1.4] 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} -ml-px" ) { |s| paths(s) } end end private def renderable? %i[success error warning info loading].include?(@variant) end def svg_classes base = "size-4" (@variant == :loading) ? "#{base} animate-spin" : base 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: "icon"}, class: "shrink-0 inline-flex items-center justify-start relative size-4 -ml-[3px] mr-1 text-foreground"} end end end
8
Add RubyUI::ToastItem to app/components/ruby_ui/toast/toast_item.rb
# frozen_string_literal: true module RubyUI class ToastItem < Base ALERT_VARIANTS = %i[error].freeze def initialize( variant: :default, id: nil, duration: nil, dismissible: true, invert: false, on_dismiss: nil, on_auto_close: nil, **attrs ) @variant = variant.to_sym @id = id @duration = duration @dismissible = dismissible @invert = invert @on_dismiss = on_dismiss @on_auto_close = on_auto_close super(**attrs) end def view_template(&) li(**attrs, &) end private def default_attrs a = { role: ALERT_VARIANTS.include?(@variant) ? "alert" : "status", aria_atomic: "true", tabindex: "0", data: { variant: @variant.to_s, state: "pending", swipe: "none", controller: "ruby-ui--toast", ruby_ui__toaster_target: "toast", ruby_ui__toast_dismissible_value: @dismissible.to_s, ruby_ui__toast_invert_value: @invert.to_s }, class: item_classes } a[:id] = @id if @id a[:data][:ruby_ui__toast_duration_value] = @duration.to_s if @duration a[:data][:ruby_ui__toast_on_dismiss_value] = @on_dismiss if @on_dismiss a[:data][:ruby_ui__toast_on_auto_close_value] = @on_auto_close if @on_auto_close a end def item_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip group/toast pointer-events-auto absolute left-0 right-0 flex w-[356px] max-w-full items-center gap-1.5 rounded-lg border bg-popover text-popover-foreground border-border p-4 text-[13px] shadow-[0_4px_12px_rgba(0,0,0,0.1)] group-data-[close-button=true]/toaster:pr-10 transition-[transform,opacity] duration-300 ease-out will-change-transform opacity-[var(--opacity,1)] data-[state=pending]:opacity-0 data-[state=closing]:opacity-0 data-[swipe=move]:transition-none CLASSES end end end
9
Add RubyUI::ToastRegion to app/components/ruby_ui/toast/toast_region.rb
# frozen_string_literal: true module RubyUI class ToastRegion < Base SKELETON_VARIANTS = %i[default success error warning info loading].freeze def initialize( position: :bottom_right, expand: false, max: 3, duration: 4000, gap: 14, offset: 24, theme: :system, rich_colors: false, close_button: false, hotkey: %w[alt t], dir: :ltr, flash: nil, **attrs ) @position = position.to_sym @expand = expand @max = max @duration = duration @gap = gap @offset = offset @theme = theme.to_sym @rich_colors = rich_colors @close_button = close_button @hotkey = hotkey @dir = dir @flash = flash super(**attrs) end def view_template(&block) div(**attrs) do ol(id: "ruby-ui-toaster", class: "pointer-events-auto relative m-0 p-0 list-none w-[356px] max-w-full") 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) render RubyUI::ToastTitle.new { message.to_s } 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 flex-col gap-0.5 flex-1 min-w-0") 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 { id: "ruby-ui-toaster-region", role: "region", aria_label: "Notifications", aria_live: "polite", data: { controller: "ruby-ui--toaster", turbo_permanent: "", close_button: @close_button.to_s, position: @position.to_s.tr("_", "-"), ruby_ui__toaster_position_value: @position.to_s.tr("_", "-"), 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_offset_value: @offset.to_s, ruby_ui__toaster_theme_value: @theme.to_s, ruby_ui__toaster_rich_colors_value: @rich_colors.to_s, ruby_ui__toaster_close_button_value: @close_button.to_s, ruby_ui__toaster_hotkey_value: Array(@hotkey).join("+"), ruby_ui__toaster_dir_value: @dir.to_s }, class: region_classes } end def region_classes <<~CLASSES.tr("\n", " ").squeeze(" ").strip group/toaster pointer-events-none fixed z-[100] p-4 sm:p-6 data-[position=top-left]:top-0 data-[position=top-left]:left-0 data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2 data-[position=top-right]:top-0 data-[position=top-right]:right-0 data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0 data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2 data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0 CLASSES end end end
10
Add RubyUI::ToastTitle to app/components/ruby_ui/toast/toast_title.rb
# frozen_string_literal: true module RubyUI class ToastTitle < Base def view_template(&) div(**attrs, &) end private def default_attrs { data: {slot: "title"}, class: "font-medium leading-normal" } 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 = 45 const TIME_BEFORE_UNMOUNT = 200 // Connects to data-controller="ruby-ui--toast" export default class extends Controller { static values = { duration: { type: Number, default: 4000 }, dismissible: { type: Boolean, default: true }, invert: { type: Boolean, default: false }, onDismiss: String, onAutoClose: String, } connect() { this._timer = null this._startedAt = 0 this._remaining = this.durationValue this._paused = false this._swipe = { active: false, x: 0, y: 0, startedAt: 0 } this._onPointerDown = this._onPointerDown.bind(this) this._onPointerMove = this._onPointerMove.bind(this) this._onPointerUp = this._onPointerUp.bind(this) this._onPointerEnter = () => this._pause() this._onPointerLeave = () => { if (!this._swipe.active) this._resume() } this._onKeyDown = this._onKeyDown.bind(this) this._onForceDismiss = (e) => { e.stopPropagation(); this._close() } this._onRestart = () => this._restart() this._onRegionPause = () => this._pause() this._onRegionResume = () => this._resume() this.element.addEventListener("pointerdown", this._onPointerDown) this.element.addEventListener("pointerenter", this._onPointerEnter) this.element.addEventListener("pointerleave", this._onPointerLeave) this.element.addEventListener("keydown", this._onKeyDown) this.element.addEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) this.element.addEventListener("ruby-ui:toast:restart", this._onRestart) document.addEventListener("ruby-ui:toast:pause", this._onRegionPause) document.addEventListener("ruby-ui:toast:resume", this._onRegionResume) requestAnimationFrame(() => { this.element.dataset.state = "open" this._start() }) } disconnect() { this._clearTimer() this.element.removeEventListener("pointerdown", this._onPointerDown) this.element.removeEventListener("pointerenter", this._onPointerEnter) this.element.removeEventListener("pointerleave", this._onPointerLeave) this.element.removeEventListener("keydown", this._onKeyDown) this.element.removeEventListener("ruby-ui:toast:force-dismiss", this._onForceDismiss) this.element.removeEventListener("ruby-ui:toast:restart", this._onRestart) document.removeEventListener("ruby-ui:toast:pause", this._onRegionPause) document.removeEventListener("ruby-ui:toast:resume", this._onRegionResume) } dismiss(e) { e?.preventDefault() if (!this.dismissibleValue) return this._close("dismiss") } _close(reason) { if (this.element.dataset.state === "closing") return this.element.dataset.state = "closing" this.element.dispatchEvent(new CustomEvent(reason === "auto" ? "ruby-ui:toast:auto-close" : "ruby-ui:toast:dismiss", { bubbles: true, detail: { id: this.element.id } })) setTimeout(() => this.element.remove(), TIME_BEFORE_UNMOUNT) } _start() { if (!Number.isFinite(this.durationValue) || this.durationValue <= 0) return this._startedAt = performance.now() this._remaining = this.durationValue this._timer = setTimeout(() => this._close("auto"), this._remaining) } _restart() { this._clearTimer() this._start() } _pause() { if (this._paused || !this._timer) return this._paused = true clearTimeout(this._timer) this._timer = null this._remaining -= performance.now() - this._startedAt } _resume() { if (!this._paused) return this._paused = false if (this._remaining <= 0) return this._close("auto") this._startedAt = performance.now() this._timer = setTimeout(() => this._close("auto"), this._remaining) } _clearTimer() { if (this._timer) clearTimeout(this._timer) this._timer = null } _onKeyDown(e) { if (e.key === "Escape" && this.dismissibleValue) this.dismiss(e) } _onPointerDown(e) { if (!this.dismissibleValue) return if (e.target.closest("button")) return try { this.element.setPointerCapture(e.pointerId) } catch {} this._swipe = { active: true, x: e.clientX, y: e.clientY, startedAt: performance.now(), pointerId: e.pointerId } this.element.dataset.swipe = "start" this.element.addEventListener("pointermove", this._onPointerMove) this.element.addEventListener("pointerup", this._onPointerUp) this.element.addEventListener("pointercancel", this._onPointerUp) } _onPointerMove(e) { const dx = e.clientX - this._swipe.x const dy = e.clientY - this._swipe.y this.element.dataset.swipe = "move" this.element.style.transform = `translate(${dx}px, ${dy}px)` } _onPointerUp(e) { const dx = e.clientX - this._swipe.x const dy = e.clientY - this._swipe.y const dist = Math.hypot(dx, dy) const dt = performance.now() - this._swipe.startedAt const velocity = dist / Math.max(dt, 1) this.element.removeEventListener("pointermove", this._onPointerMove) this.element.removeEventListener("pointerup", this._onPointerUp) this.element.removeEventListener("pointercancel", this._onPointerUp) this._swipe.active = false if (dist > SWIPE_THRESHOLD || velocity > 0.5) { this.element.style.setProperty("--swipe-end-x", `${Math.sign(dx) * 500}px`) this.element.style.setProperty("--swipe-end-y", `${Math.sign(dy) * 500}px`) this.element.dataset.swipe = "end" this.element.style.transform = "" this._close("dismiss") } else { this.element.dataset.swipe = "cancel" this.element.style.transform = "" this._resume() } } }
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"] 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" 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: 4000 }, gap: { type: Number, default: 14 }, offset: { type: Number, default: 24 }, theme: { type: String, default: "system" }, richColors: { type: Boolean, default: false }, closeButton: { type: Boolean, default: false }, hotkey: { type: String, default: "alt+t" }, dir: { type: String, default: "ltr" }, } connect() { this._heights = new Map() this._resizeObservers = new WeakMap() this._expanded = this.expandValue this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null) this._registerGlobalApi() registerStreamAction() if (!this._listEl) return this._onPointerEnter = () => this._setExpanded(true) this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) } this._onWindowToast = (e) => this._spawn(e.detail || {}) this._onWindowDismissAll = () => this._dismissById(null) this._onKey = this._onKey.bind(this) window.addEventListener("ruby-ui:toast", this._onWindowToast) window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) this._listEl.addEventListener("pointerenter", this._onPointerEnter) this._listEl.addEventListener("pointerleave", this._onPointerLeave) document.addEventListener("keydown", this._onKey) } disconnect() { window.removeEventListener("ruby-ui:toast", this._onWindowToast) window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll) this._listEl?.removeEventListener("pointerenter", this._onPointerEnter) this._listEl?.removeEventListener("pointerleave", this._onPointerLeave) document.removeEventListener("keydown", this._onKey) } toastTargetConnected(el) { if (typeof ResizeObserver !== "undefined") { const ro = new ResizeObserver(() => { this._heights.set(el, el.offsetHeight) this._reflow() }) ro.observe(el) this._resizeObservers.set(el, ro) } this._heights.set(el, el.offsetHeight || 64) this._reflow() } toastTargetDisconnected(el) { this._resizeObservers.get(el)?.disconnect() this._resizeObservers.delete(el) this._heights.delete(el) this._reflow() } _spawn(detail) { const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default" const tpl = this._skeletonFor(variant) if (!tpl) return null if (detail.position) { this.element.setAttribute("data-position", detail.position) this.positionValue = detail.position } const node = tpl.content.firstElementChild.cloneNode(true) node.id = detail.id || `toast-${this._uuid()}` if (detail.duration != null) { const dur = detail.duration === Infinity ? 0 : detail.duration node.setAttribute("data-ruby-ui--toast-duration-value", String(dur)) } if (detail.dismissible === false) { node.setAttribute("data-ruby-ui--toast-dismissible-value", "false") } if (detail.className) node.className += ` ${detail.className}` const titleEl = node.querySelector('[data-slot="title"]') if (titleEl) titleEl.textContent = detail.title || detail.message || "" const descEl = node.querySelector('[data-slot="description"]') if (descEl) { if (detail.description) descEl.textContent = detail.description else descEl.remove() } if (detail.action && detail.action.label && this.hasActionTplTarget) { const btn = this._cloneSlot(this.actionTplTarget) btn.textContent = detail.action.label btn.addEventListener("click", (ev) => { try { detail.action.onClick?.(ev) } finally { node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) } }) node.appendChild(btn) } if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) { const btn = this._cloneSlot(this.cancelTplTarget) btn.textContent = detail.cancel.label node.appendChild(btn) } if (detail.closeButton && this.hasCloseTplTarget) { const x = this._cloneSlot(this.closeTplTarget) node.classList.add("pr-10") node.appendChild(x) } this._listEl.appendChild(node) return node.id } _dismissById(id) { if (!id) { this.toastTargets.forEach((el) => el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) ) return } const el = this._listEl.querySelector(`#${CSS.escape(id)}`) if (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) } _setExpanded(value) { if (this._expanded === value) return this._expanded = value document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume")) this._reflow() } _reflow() { if (!this._listEl) return const isBottom = this.positionValue.startsWith("bottom") const items = this.toastTargets const order = isBottom ? items.slice().reverse() : items.slice() const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64) const gap = this.gapValue const peekOffset = 16 const peekScaleStep = 0.05 const peekOpacityStep = 0.2 const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1) const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px` let acc = 0 order.forEach((el, i) => { const visible = i < this.maxValue let yOffset, scale, opacity if (this._expanded) { yOffset = acc + i * gap scale = 1 opacity = visible ? 1 : 0 } else { yOffset = i * peekOffset scale = Math.max(0.85, 1 - i * peekScaleStep) opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0 } const sign = isBottom ? -1 : 1 const ty = sign * yOffset el.style.setProperty("--opacity", String(opacity)) el.style.setProperty("--scale", String(scale)) el.style.setProperty("--y-offset", `${ty}px`) el.style.transformOrigin = isBottom ? "center bottom" : "center top" el.style.top = isBottom ? "auto" : "0" el.style.bottom = isBottom ? "0" : "auto" el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})` el.style.zIndex = String(1000 - i) el.style.pointerEvents = visible ? "auto" : "none" el.tabIndex = visible ? 0 : -1 acc += heights[i] || 0 }) this._enforceMax(items) } _enforceMax(items) { if (items.length <= this.maxValue) return const isBottom = this.positionValue.startsWith("bottom") const dropping = items.length - this.maxValue const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping) candidates.forEach(el => { if (el.dataset.state !== "closing") { el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true })) } }) } _onKey(e) { const parts = (this.hotkeyValue || "alt+t").split("+") const key = parts.pop() const wantAlt = parts.includes("alt") const wantCtrl = parts.includes("ctrl") const wantMeta = parts.includes("meta") if (e.key.toLowerCase() !== key.toLowerCase()) return if (wantAlt !== e.altKey) return if (wantCtrl !== e.ctrlKey) return if (wantMeta !== e.metaKey) return e.preventDefault() const first = this._listEl.firstElementChild first?.focus() } _registerGlobalApi() { const fire = (variant, message, opts = {}) => window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail: { ...opts, variant, message: opts.title || message } })) 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.promise = (p, msgs = {}) => { const id = `toast-${this._uuid()}` fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, 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.setAttribute("role", variant === "error" ? "alert" : "status") this._swapIcon(el, variant) const t = el.querySelector('[data-slot="title"]') if (t && text) t.textContent = text const dur = String(this.durationValue) el.setAttribute("data-ruby-ui--toast-duration-value", dur) el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true })) } _swapIcon(el, variant) { const iconHost = el.querySelector('[data-slot="icon"]') if (!iconHost) return const tpl = this._skeletonFor(variant) if (!tpl) return const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="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
Importmap!
rake stimulus:manifest:update
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
Toaster (Region)
| 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. |
expand | false | Boolean | Always show items expanded (no stack peek). |
max | 3 | Integer | Max visible toasts before oldest auto-evicts. |
duration | 4000 | Integer (ms) | Default lifetime per toast. Pass 0 or Infinity to disable auto-dismiss. |
gap | 14 | Integer (px) | Spacing between toasts when expanded. |
offset | 24 | Integer (px) | Distance from the viewport edge. |
theme | :system | :system | :light | :dark | Color scheme override. |
rich_colors | false | Boolean | Enable variant-tinted backgrounds. |
close_button | false | Boolean | Render an X button in every toast (top-right). |
hotkey | %w[alt t] | Array<String> | Keyboard shortcut to focus the first toast. |
dir | :ltr | :ltr | :rtl | Text direction. |
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 | Visual + a11y role + icon. |
id | nil | String | DOM id; auto-generated when not provided. |
duration | nil | Integer | nil | Override the Region default. nil inherits. |
dismissible | true | Boolean | Allow Escape, swipe, X, and force-dismiss to close. |
invert | false | Boolean | Invert background/foreground (light-on-dark in light theme). |
on_dismiss | nil | String | Stimulus action descriptor fired when the user dismisses. |
on_auto_close | nil | String | Stimulus action descriptor fired when the timer expires. |
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. Infinity = sticky. |
action | — | { label, onClick } | Primary action button rendered inside the toast. |
cancel | — | { label, onClick } | Secondary dismiss button. |
closeButton | false | Boolean | Force an X close button on this toast. |
position | (Region default) | String | Per-toast position override (changes Region's data-position before append). |
id | (auto) | String | Set a stable id (used by .dismiss(id) and .promise). |
dismissible | true | Boolean | Disable Escape / swipe / dismiss-all for this toast. |
className | — | String | Extra classes appended to the rendered <li>. |