Flash Bridge
stimeo--flash
Turns Rails flash messages into notifications that reach screen readers and clear themselves.
Turns Rails flash messages into notifications a screen reader will hear. Notices and warnings are announced with different urgency, so an important warning is not buried. They clear themselves after a while, and never while the pointer is over one or focus is inside it, so nothing disappears mid-read. You can cap how many show at once. Messages inserted later by Turbo are found and treated the same way. A notification appearing never steals focus. Add a close button and they can be dismissed by hand. Whether one is on its way out is readable from CSS, so you can write the exit animation.
Keyboard
This component has no keyboard interactions of its own.
<%# Flash-bridge demo: the buttons append flash messages into the region (standing in
for a Turbo Stream that renders server flash). The controller maps data-flash-type
to role=status/alert, auto-dismisses after the duration (paused on hover/focus), and
a close button wired to the dismiss action removes one. The library only sets roles
and data-flash-state; demo.css owns the look and the visible/leaving transition. The
message text and the dismiss label are owned here for i18n. %>
<div class="flash-demo" data-dismiss-label="<%= t("components.flash.demo.dismiss") %>">
<div class="flash-demo__bar">
<button
type="button"
class="demo-trigger"
data-flash-demo="notice"
data-flash-text="<%= t("components.flash.demo.notice_text") %>">
<%= t("components.flash.demo.notice") %>
</button>
<button
type="button"
class="demo-trigger"
data-flash-demo="alert"
data-flash-text="<%= t("components.flash.demo.alert_text") %>">
<%= t("components.flash.demo.alert") %>
</button>
</div>
<div data-controller="stimeo--flash" data-stimeo--flash-duration-value="4000">
<div class="flash-demo__region" data-stimeo--flash-target="region"></div>
</div>
</div>
/*
* Presentation-only styles for the flash demo. The library maps each message to
* role=status/alert and reflects data-flash-state (visible / leaving); this CSS owns
* the banner look (colored by data-flash-type) and the fade on the leaving state.
*/
.flash-demo {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 28rem;
}
.flash-demo__bar {
display: flex;
gap: 0.5rem;
}
.flash-demo__region {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.flash-demo__item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
transition: opacity 0.2s ease-out;
}
.flash-demo__item[data-flash-type="notice"] {
border-color: var(--leaf-500);
background: var(--leaf-50);
color: var(--leaf-500);
}
.flash-demo__item[data-flash-type="alert"] {
border-color: var(--danger-500);
background: var(--danger-50);
color: var(--color-accent);
}
.flash-demo__item[data-flash-state="leaving"] {
opacity: 0;
}
.flash-demo__close {
flex: none;
border: 0;
background: transparent;
font-size: 1.125rem;
line-height: 1;
color: inherit;
cursor: pointer;
}
// Flash-bridge demo (consumer-side JS).
//
// This catalog has no Turbo Stream backend, so the buttons append a flash message into
// the region — exactly the mutation a Turbo Stream flash append would make — and the
// controller picks it up via its MutationObserver, maps the role, and auto-dismisses it.
// The message text and the dismiss label come from data-* so they stay i18n'd.
document.querySelectorAll(".flash-demo").forEach((root) => {
const region = root.querySelector('[data-stimeo--flash-target="region"]');
if (!region) return;
// Idempotent: Turbo can re-run this inline module on navigation; wire each root once
// so a click never appends two flashes. The marker is a property, not an attribute:
// Turbo copies attributes into its page snapshot, so an attribute one comes back set
// on a restored page whose elements carry no listeners.
if (root.demoWired) return;
root.demoWired = true;
const dismissLabel = root.dataset.dismissLabel ?? "Dismiss";
root.querySelectorAll("[data-flash-demo]").forEach((button) => {
button.addEventListener("click", () => {
const flash = document.createElement("div");
flash.className = "flash-demo__item";
flash.setAttribute("data-stimeo--flash-target", "message");
flash.setAttribute("data-flash-type", button.dataset.flashDemo);
const text = document.createElement("span");
text.textContent = button.dataset.flashText ?? "";
flash.appendChild(text);
const close = document.createElement("button");
close.type = "button";
close.className = "flash-demo__close";
close.setAttribute("data-action", "click->stimeo--flash#dismiss");
close.setAttribute("aria-label", dismissLabel);
close.textContent = "×"; // ×
flash.appendChild(close);
region.appendChild(flash);
});
});
});
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--flash"
Targets
| Name | Description | Attribute |
|---|---|---|
region
required
|
The container that holds the flash messages (the observed stack). | data-stimeo--flash-target="region" |
message
|
An individual flash; carries data-flash-type and is auto-managed. |
data-stimeo--flash-target="message" |
Values
| Name | Description | Attribute |
|---|---|---|
duration
|
Milliseconds before auto-dismiss (0 = never auto-dismiss; default 5000). | data-stimeo--flash-duration-value |
pauseOnHover
|
Pause the auto-dismiss timer while a message is hovered or focused (default true). |
data-stimeo--flash-pause-on-hover-value |
max
|
Maximum simultaneous messages; the oldest are dropped past it (0 = unlimited). | data-stimeo--flash-max-value |
Actions
| Name | Action |
|---|---|
dismiss
|
stimeo--flash#dismiss |
Events
| Name | Description | Event |
|---|---|---|
show
|
Fires when a flash is shown, with detail.type / detail.message. |
stimeo--flash:show |
dismiss
|
Fires when a flash is removed, with detail.element / detail.reason. |
stimeo--flash:dismiss |
reconcile
|
Fires when the Turbo cache rewind removes messages; detail carries how many were removed. |
stimeo--flash:reconcile |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-flash-state |
Each message | visible while shown, leaving while animating out before removal. |
role |
Each message | status for notice, alert for alert / error (mapped from data-flash-type). |