Flash Bridge
stimeo--flash
Turns Rails flash elements into live-region announcements with auto-dismiss and stacking.
The stimeo--flash controller bridges Rails flash elements (data-flash-type="notice|alert") into accessible notifications: it maps each message's type to role="status" (notice) or role="alert" (alert / error), flags it data-flash-state="visible", auto-dismisses it after duration (paused while hovered or focused when pauseOnHover, per WCAG 2.2.1), and caps the stack at max simultaneous messages. Reading is delegated to the shared Announcer — but only for the initial, page-loaded flashes, because an in-place live region present at load is not announced on its own; messages inserted later by Turbo Stream are announced by their own freshly inserted role, so they are not bridged twice. Dynamic inserts are detected with a MutationObserver, and a close control wired to the dismiss action removes one manually. Behavior only — no styling (data-flash-state="leaving" lets CSS animate removal); focus is never moved (WCAG 2.2 4.1.3), and the observer, timers, and per-message listeners are torn down on disconnect (Turbo navigation included).
<%# 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.
if (root.dataset.demoWired) return;
root.dataset.demoWired = "1";
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", "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 |
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). |