Announcer
stimeo--announcer
Shared polite/assertive live-region base for screen-reader announcements.
The stimeo--announcer controller provides one pair of polite/assertive live regions other behaviors can lean on instead of each carrying their own. Announce a message via the announce action param (attribute-only) or by dispatching the stimeo--announcer:announce custom event with detail.message (and optional detail.assertive) — handy for Turbo Stream updates and async results. It never moves focus (announcements must not steal it, WCAG 2.2 4.1.3); re-announces identical text by clearing then re-setting so an atomic region speaks again; and auto-clears after clearAfter. If a polite/assertive target is absent it generates a visually-hidden region at runtime. Listeners, timers, and any generated regions are torn down on disconnect (Turbo included). Behavior only — the regions' hidden styling is owned by this Playground.
The live regions are visually hidden, so a screen reader announces each message while the transcript below mirrors it for sighted viewers.
<%# Markup for the live-region announcer demo.
The library owns one pair of polite/assertive live regions; triggers announce a
message into the matching region via the announce action param (attribute-only).
The regions are visually hidden (screen-reader only), so demo.js mirrors each
announcement into a visible transcript for sighted viewers. %>
<%
polite_msg = t("components.announcer.demo.polite_message")
alert_msg = t("components.announcer.demo.assertive_message")
%>
<%# The controller wraps the whole demo so the attribute-only trigger buttons sit
within its scope — a Stimulus action binds to a controller on the element or an
ancestor, so triggers placed outside the controller element never fire. %>
<div class="announcer-demo" data-controller="stimeo--announcer">
<div class="announcer-demo__controls">
<button class="demo-trigger" type="button"
data-action="click->stimeo--announcer#announce"
data-stimeo--announcer-message-param="<%= polite_msg %>">
<%= t("components.announcer.demo.announce_polite") %>
</button>
<button class="demo-trigger" type="button"
data-action="click->stimeo--announcer#announce"
data-stimeo--announcer-message-param="<%= alert_msg %>"
data-stimeo--announcer-assertive-param="true">
<%= t("components.announcer.demo.announce_assertive") %>
</button>
</div>
<%# Place once per page. The consumer (this Playground) owns the visually-hidden
styling via .visually-hidden; the library only writes the message text. %>
<div class="visually-hidden"
data-stimeo--announcer-target="polite" aria-live="polite" aria-atomic="true"></div>
<div class="visually-hidden"
data-stimeo--announcer-target="assertive" aria-live="assertive" aria-atomic="true"></div>
<p class="announcer-demo__note"><%= t("components.announcer.demo.note") %></p>
<%# Visible echo of what was announced (the live regions themselves are hidden). %>
<ul class="announcer-log" data-announcer-log aria-hidden="true"></ul>
</div>
/*
* Presentation-only styles for the live-region announcer demo.
* The library only writes message text into the regions; this CSS owns the
* visually-hidden ("sr-only") treatment and the visible transcript styling.
*/
.announcer-demo {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 32rem;
}
.announcer-demo__controls {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* The live regions use the shared `.visually-hidden` base primitive (kept in the
* a11y tree, removed from view) — no per-demo duplication. */
.announcer-demo__note {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.announcer-log {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.announcer-log li {
padding: 0.4rem 0.6rem;
border: 1px solid var(--border);
border-radius: 0.375rem;
font-size: 0.9rem;
font-family: ui-monospace, monospace;
}
// Consumer-side JS for the live-region announcer demo (demo-only).
// The live regions are visually hidden (screen-reader only), so this mirror echoes
// each announcement into a visible transcript for sighted viewers. It also shows
// the programmatic path: any code can announce by dispatching the custom event,
// e.g. window.dispatchEvent(new CustomEvent("stimeo--announcer:announce",
// { detail: { message: "Saved", assertive: false } })).
const log = document.querySelector("[data-announcer-log]");
const regions = document.querySelectorAll("[data-stimeo--announcer-target]");
regions.forEach((region) => {
const observer = new MutationObserver(() => {
const text = (region.textContent || "").trim();
if (!text || !log) return;
const level = region.getAttribute("aria-live");
const item = document.createElement("li");
item.textContent = `[${level}] ${text}`;
log.prepend(item);
});
observer.observe(region, { childList: true, characterData: true, subtree: true });
});
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--announcer"
Targets
| Name | Description | Attribute |
|---|---|---|
polite
|
The aria-live="polite" region for non-urgent messages; generated if absent. |
data-stimeo--announcer-target="polite" |
assertive
|
The aria-live="assertive" region for urgent messages; generated if absent. |
data-stimeo--announcer-target="assertive" |
Values
| Name | Description | Attribute |
|---|---|---|
clearAfter
|
Milliseconds after which the region text is cleared; 0 disables (default 1000). | data-stimeo--announcer-clear-after-value |
dedupeReannounce
|
Whether identical text is re-announced via a clear-then-reset (default true). |
data-stimeo--announcer-dedupe-reannounce-value |
Actions
| Name | Description | Action |
|---|---|---|
announce
|
Announces a message from the action param (message, optional assertive) or event detail. |
stimeo--announcer#announce |