Toast
Flashes and stream responses as toasts that dismiss themselves, pause while hovered or focused, and survive Drive visits and morph refreshes.
JavaScript API
Show, update and dismiss notifications without a server request. Try an async action, a rejected action, or a scoped panel.
<unmagic-toast-demo class="block space-y-4">
<div class="flex flex-wrap gap-2">
<%= button_tag "Show notification", type: "button", class: button_classes, data: { demo: "show" } %>
<%= button_tag "Update to success", type: "button", class: button_classes, data: { demo: "update" } %>
<%= button_tag "Dismiss latest", type: "button", class: button_classes, data: { demo: "dismiss" } %>
<%= button_tag "Dismiss all", type: "button", class: button_classes, data: { demo: "all" } %>
<%= button_tag "Async action", type: "button", class: button_classes, data: { demo: "async" } %>
<%= button_tag "Action with an error", type: "button", class: button_classes, data: { demo: "error" } %>
</div>
<output class="block text-sm text-neutral-600 dark:text-neutral-400" aria-live="polite">Show a notification, then update or dismiss it.</output>
<div class="relative min-h-64 rounded-lg border border-neutral-200 p-4 dark:border-neutral-800">
<%= button_tag "Notify this panel", type: "button", class: button_classes, data: { demo: "scoped" } %>
<%= flash_toasts({}, id: "toast_api_panel", scoped: true, position: :bottom_end) %>
</div>
</unmagic-toast-demo>
<script type="module">
import { toast } from "unmagic/components/toasts";
if (!customElements.get("unmagic-toast-demo")) {
customElements.define("unmagic-toast-demo", class extends HTMLElement {
#latest;
constructor() {
super();
this.addEventListener("click", (event) => {
const action = event.target.closest("[data-demo]")?.dataset.demo;
const output = this.querySelector("output");
if (action === "show") {
this.#latest = toast.show("Preparing your export…", { duration: 0, tone: "info" });
output.textContent = "Notification shown. Update it when ready.";
} else if (action === "update") {
const updated = toast.update(this.#latest, { message: "Export ready.", tone: "good", duration: 5000 });
output.textContent = updated ? "Updated. It closes after five seconds." : "Show a notification first.";
} else if (action === "dismiss") {
output.textContent = toast.dismiss(this.#latest) ? "Dismissed." : "No active notification.";
} else if (action === "all") {
output.textContent = `Dismissed ${toast.dismissAll()} notifications from the page mount.`;
} else if (action === "scoped") {
toast.show("Saved inside this panel.", { target: "toast_api_panel", duration: 0 });
} else if (action === "async") {
toast.show("Ready to archive this item?", {
duration: 0, closeButton: false,
actions: [
{ label: "Archive", onClick: async () => {
output.textContent = "Archiving…";
await new Promise(resolve => setTimeout(resolve, 1200));
if (this.isConnected) output.textContent = "Archived. The notification closed after completion.";
} },
{ label: "Dismiss" },
],
});
} else if (action === "error") {
const id = toast.show("Try saving this draft.", {
duration: 0,
actions: [{ label: "Save", onClick: async () => {
await new Promise(resolve => setTimeout(resolve, 700));
throw new Error("The demo server is unavailable.");
} }],
});
const mount = document.getElementById("unmagic_toasts");
const controller = new AbortController();
mount.addEventListener("unmagic-toast:action-error", (event) => {
if (event.detail.id !== id) return;
toast.update(id, { message: event.detail.error.message, tone: "bad" });
}, { signal: controller.signal });
mount.addEventListener("unmagic-toast:dismiss", (event) => {
if (event.detail.id === id) controller.abort();
}, { signal: controller.signal });
}
});
}
});
}
</script>Rails and JavaScript together
The response gives the toast a logical id. JavaScript can dismiss it using that id.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<unmagic-rails-toast-demo class="flex flex-wrap gap-2">
<%# The response calls turbo_stream.toast(message, toast_id: a_unique_id, duration: 0). %>
<%= button_to "Stream from Rails", stream_toast_path,
params: { example: "javascript_rails", message: "Created by Rails. Dismiss me from JavaScript." }, class: button_classes %>
<%= button_tag "Dismiss from JavaScript", type: "button", class: button_classes, data: { dismiss_rails: "" } %>
</unmagic-rails-toast-demo>
<script type="module">
import { toast } from "unmagic/components/toasts";
if (!customElements.get("unmagic-rails-toast-demo")) {
customElements.define("unmagic-rails-toast-demo", class extends HTMLElement {
#latest;
#shown = (event) => {
if (event.detail.id.startsWith("browser-export-")) this.#latest = event.detail.id;
};
constructor() {
super();
this.addEventListener("click", (event) => {
if (event.target.closest("[data-dismiss-rails]")) toast.dismiss(this.#latest);
});
}
connectedCallback() { document.addEventListener("unmagic-toast:show", this.#shown); }
disconnectedCallback() { document.removeEventListener("unmagic-toast:show", this.#shown); }
});
}
</script>From a flash, after a redirect
Each button sets flash[type] and redirects back. Hover a toast to hold it open.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<div class="flex flex-wrap gap-2">
<%= button_to "Notice", flash_toast_path, params: { type: "notice", message: "Changes saved." },
class: button_classes(:primary) %>
<%= button_to "Alert", flash_toast_path, params: { type: "alert", message: "Couldn't save your changes." },
class: button_classes %>
<%= button_to "Warning", flash_toast_path, params: { type: "warning", message: "Your trial ends in 3 days." },
class: button_classes %>
<%= button_to "Info", flash_toast_path, params: { type: "info", message: "A new version is available." },
class: button_classes %>
<%= button_to "Long", flash_toast_path,
params: { type: "notice", message: "Imported 1,204 contacts from contacts-export-2026-09-16-final-final.csv into the Sales pipeline." },
class: button_classes %>
</div>From a stream response
turbo_stream.toast pops one without a redirect.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<div class="flex flex-wrap gap-2">
<%= button_to "Stream a toast", stream_toast_path, params: { tone: "good", message: "Copied to your clipboard." },
class: button_classes %>
<%= button_to "Stream a bad toast", stream_toast_path, params: { tone: "bad", message: "Couldn't reach Slack." },
class: button_classes(:danger) %>
</div>Title, icon and action
A heading adds context; actions are ordinary Rails buttons or links.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# The stream response calls turbo_stream.toast(message, title: "All set", icon: :circle_check)
and captures toast.actions with a data-unmagic-toast-dismiss button. %>
<%= button_to "Show title and action", stream_toast_path, params: { example: "content" }, class: button_classes %>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast("Your workspace is ready for the team.", title: "All set", icon: :circle_check, **options) do |toast| %>
<% toast.actions do %>
<%= button_tag "Got it", type: "button", class: button_classes(size: :small), data: { unmagic_toast_dismiss: "" } %>
<% end %>
<% end %>Image and actions
A toast can contain a title, description, image, and buttons.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%= button_to "Show image toast", stream_toast_path, params: { example: "image", tone: "info" },
class: button_classes(:primary) %>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast(tone: :info, **options) do |toast| %>
<% toast.body do %>
<%= render partial: "unmagic/components/browser/examples/toast/image_content", formats: [ :html ] %>
<% end %>
<% end %>Upload in progress
A progress component inside a toast appears in the top-right stack.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%= button_to "Show upload toast", stream_toast_path, params: { example: "upload", tone: "info" },
class: button_classes(:primary) %>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast(tone: :info, **options) do |toast| %>
<% toast.body do %>
<%= render partial: "unmagic/components/browser/examples/toast/upload_content", formats: [ :html ] %>
<% end %>
<% end %>Tones
Good, warn, bad and info, plus neutral, accent and inverted surfaces.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<div class="flex flex-wrap gap-2">
<% %w[good warn bad info neutral accent inverted].each do |tone| %>
<%= button_to tone.capitalize, stream_toast_path,
params: { tone: tone, message: "#{tone.capitalize}: your notification is here." }, class: button_classes %>
<% end %>
</div>Six positions
Place each toast independently. Start and end follow the reading direction.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# turbo_stream.toast("Placed independently.", position: :bottom_start) %>
<div class="grid grid-cols-3 gap-2">
<% %w[top_start top top_end bottom_start bottom bottom_end].each do |position| %>
<%= button_to position.humanize, stream_toast_path,
params: { position: position, message: "Hello from #{position.humanize.downcase}." }, class: button_classes %>
<% end %>
</div>Duration and sticky toasts
Mix timed and sticky messages. A duration of 0 waits for the dismiss button.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# turbo_stream.toast("Here until you dismiss it.", duration: 0) %>
<div class="flex flex-wrap gap-2">
<%= button_to "Short · 2 seconds", stream_toast_path,
params: { duration: 2000, message: "A quick update. This closes after two seconds." }, class: button_classes %>
<%= button_to "Long · 10 seconds", stream_toast_path,
params: { duration: 10000, message: "Take your time. This stays for ten seconds." }, class: button_classes %>
<%= button_to "Sticky · dismiss manually", stream_toast_path,
params: { duration: 0, message: "Here until you dismiss it.", tone: "info" }, class: button_classes %>
</div>Widths
Short, long or a pixel width; each stays within the screen on a phone.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# turbo_stream.toast(message, width: :long) also accepts a positive pixel integer. %>
<div class="flex flex-wrap gap-2">
<% { "Short · 384px" => "short", "Long · 560px" => "long", "Custom · 440px" => "440" }.each do |label, width| %>
<%= button_to label, stream_toast_path,
params: { width: width, message: "Your project has been saved. Everyone in the workspace can now see the latest changes." }, class: button_classes %>
<% end %>
</div>Action layouts
Give horizontal actions room with width: :long, or place them below with layout: :vertical. Narrow toasts stack automatically. close_button: false hides the × when a Dismiss action is provided.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# In the response, toast.actions captures normal links and buttons.
layout: :vertical places the actions below the message. %>
<div class="flex flex-wrap gap-2">
<% %w[horizontal vertical].each do |layout| %>
<%= button_to layout.capitalize, stream_toast_path,
params: { example: "actions", layout: layout, tone: "neutral" }, class: button_classes %>
<% end %>
</div>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast("The export is ready to review.", title: "Export complete", duration: 0, close_button: false,
width: (options[:layout] == :vertical ? :short : :long), **options) do |toast| %>
<% toast.actions do %>
<%= link_to "View examples", component_path("toast", anchor: "toast_flash"), class: button_classes(size: :small), data: { unmagic_toast_dismiss: "" } %>
<%= button_tag "Dismiss", type: "button", class: button_classes(size: :small), data: { unmagic_toast_dismiss: "" } %>
<% end %>
<% end %>Custom leading content
Use the leading slot for an avatar or other context instead of the default icon.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# In the response: toast.leading { avatar "Alex Morgan", size: :small } %>
<%= button_to "Show avatar notification", stream_toast_path,
params: { example: "leading", tone: "neutral" }, class: button_classes %>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast("Alex joined the workspace.", title: "A new teammate", **options) do |toast| %>
<% toast.leading do %>
<%= avatar "Alex Morgan", size: :small %>
<% end %>
<% end %>Custom content
Capture a body slot for a composed announcement. The close button stays available.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%# The response captures toast.body, keeping the standard container and close button.
It uses duration: 0, position: :bottom_start, width: :long and icon: false. %>
<%= button_to "Show announcement", stream_toast_path,
params: { example: "custom" }, class: button_classes %>
<%# Turbo Stream response (options contains the selected demo options): %>
<%= turbo_stream.toast(tone: :neutral, position: :bottom_start, duration: 0, width: :long, icon: false) do |toast| %>
<% toast.body do %>
<div class="space-y-3">
<div class="flex items-center gap-2">
<%= badge "New", tone: :accent %>
<strong>A little more room to create</strong>
</div>
<p>Shared workspaces now include project notes. Keep decisions and next steps together.</p>
<%= link_to "Explore the components", components_path, class: button_classes(size: :small), data: { unmagic_toast_dismiss: "" } %>
</div>
<% end %>
<% end %>Inside a panel
A named, scoped mount contains notifications within a positioned panel.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<div class="relative min-h-80 rounded-lg border border-neutral-200 bg-neutral-50 p-4 dark:border-neutral-800 dark:bg-neutral-950">
<%= button_to "Notify inside this panel", stream_toast_path,
params: { example: "boundaries", message: "This notification stays inside its panel.", position: "bottom_end", duration: 0 },
class: button_classes %>
<%= flash_toasts({}, id: "toast_panel", scoped: true) %>
<%# Stream with: turbo_stream.toast(message, target: "toast_panel", position: :bottom_end, duration: 0) %>
</div>Above an open dialog
A toast streamed from a dialog shows above it, and times out even though the dialog makes it inert.
This example talks to a server
bin/dev) or mount it in an app to try it for real.
<%= dialog_button "Open a dialog", dialog: "toast_over_dialog", class: button_classes %>
<%= dialog_tag "toast_over_dialog", title: "A dialog" do %>
<p>
A toast streamed from in here shows above the dialog, not under its backdrop. It can't be
hovered or dismissed until the dialog closes, but it still times out on its own.
</p>
<%= button_to "Stream a toast", stream_toast_path, params: { tone: "info", message: "Above the dialog." },
class: button_classes(:primary) %>
<% end %>