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, and the cap passes over one the pointer is on or focus is inside. 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. max caps the stack at three:
past it the oldest message goes, passing over any the pointer is over or focus is
inside until that is released. 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"
data-stimeo--flash-max-value="3">
<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-700);
}
.flash-demo__item[data-flash-type="alert"] {
border-color: var(--danger-500);
background: var(--danger-50);
color: var(--danger-700);
}
.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). With false, a hovered or focused message still stays out of the cap's reach. |
data-stimeo--flash-pause-on-hover-value |
max
|
Maximum simultaneous messages; past it the oldest go, skipping any that is hovered or focused, and the cap applies again once they are released (0 = unlimited). | data-stimeo--flash-max-value |
Actions
| Name | Action |
|---|---|
dismiss
|
stimeo--flash#dismiss |
Events
| Name | Description | Event |
|---|---|---|
show
|
Fires once when a flash is shown, with detail.type / detail.message; reconnecting the same instance does not fire it again. |
stimeo--flash:show |
dismiss
|
Fires when a flash is removed, with detail.element / detail.reason. detail.reason reports user / focus / pointer / api / timeout / limit. |
stimeo--flash:dismiss |
reconcile
|
Fires when connecting removes messages shown earlier that a page restored from the Turbo cache still carried; 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). |