Announcer
stimeo--announcer
One place to tell screen readers what changed. Put it on the page once and every part can use it.
One place to tell screen readers about a change they would otherwise miss. Put it on the page once and the other parts no longer need a speaking region of their own. There are two ways to send a message: straight from markup, or from any JavaScript you write. It suits a Turbo update that swaps content and any asynchronous result worth announcing. It never moves focus in order to speak. Sending the same wording twice speaks it twice. A message clears itself shortly afterwards, so an old message is not found later by someone reading back.
The live regions are visually hidden, so a screen reader announces each message while the transcript below mirrors it for sighted viewers.
Keyboard
This component has no keyboard interactions of its own.
<%# 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-label="<%= t('components.announcer.demo.label_polite') %>"
data-stimeo--announcer-target="polite" aria-live="polite" aria-atomic="true"></div>
<div class="visually-hidden" data-label="<%= t('components.announcer.demo.label_assertive') %>"
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;
// Prefer the author-localized label (data-label) over the raw aria-live token.
const level = region.getAttribute("data-label") || 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 |