A Vue Announcer Composable for Data Updates
Permalink to "A Vue Announcer Composable for Data Updates"Every accessible data component needs to announce something: “Sorted by Amount”, “42 invoices”, “Could not load”. If each component renders its own live region, messages collide, regions get created and destroyed with components (and never announce), and identical messages are swallowed. A single announcer — rendered once at the app root, called from anywhere — solves all three. In Vue 3, a composable is the natural shape for it.
This guide belongs to Vue & Nuxt data components.
Spec reference
Permalink to "Spec reference"- SC 4.1.3 Status Messages (AA) — status messages must be programmatically determinable; persistent
role="status"androle="alert"regions are the standard technique. - Live region timing — regions must exist before their content changes; screen readers may skip identical consecutive text.
- Politeness — polite for routine updates, assertive for errors that block the task.
When to use — and when not to
Permalink to "When to use — and when not to"Use a single announcer in any Vue app with more than one component that announces. Call it for results of user actions and for background events worth knowing about.
Do not use it for content that already has focus (a dialog heading after opening) or for information the focused control already conveys. The misapplication: calling announce on every keystroke in a filter field, which the queue can mitigate with keys and debouncing, but which still indicates the wrong design.
Annotated code example
Permalink to "Annotated code example"// composables/useAnnouncer.js
import { ref } from 'vue';
const polite = ref(''); const assertive = ref('');
const queue = new Map(); let timer = null;
function flush() {
timer = null;
const items = [...queue.values()]; queue.clear();
const msg = (kind) => items.filter(i => i.politeness === kind).map(i => i.message).join(' ');
for (const [kind, target] of [['polite', polite], ['assertive', assertive]]) {
const text = msg(kind); if (!text) continue;
target.value = ''; // clear so repeats are announced
requestAnimationFrame(() => { target.value = text; });
}
}
export function useAnnouncer() {
function announce(message, { politeness = 'polite', key = message, delay = 250 } = {}) {
queue.set(key, { message, politeness }); // same key replaces earlier message
clearTimeout(timer); timer = setTimeout(flush, delay); // short debounce merges bursts
}
return { announce, polite, assertive };
}
<!-- App.vue: regions rendered once (SC 4.1.3) -->
<template>
<RouterView />
<div role="status" class="visually-hidden">{{ polite }}</div>
<div role="alert" class="visually-hidden">{{ assertive }}</div>
</template>
<script setup>
import { useAnnouncer } from './composables/useAnnouncer';
const { polite, assertive } = useAnnouncer();
</script>
// In a table component
const { announce } = useAnnouncer();
watch(sortBy, (s) => announce(`Sorted by ${label(s.key)}, ${s.order}`, { key: 'sort' }));
watch(filteredCount, (n) => announce(`${n} invoices shown`, { key: 'count', delay: 500 }));
onError(() => announce('Could not load invoices. Try again.', { politeness: 'assertive', key: 'load-error' }));
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Messages queued | Spoken |
|---|---|---|
| Sort then count within 250 ms | “Sorted by Amount, ascending”, “240 invoices shown” | Both, in one utterance |
| Filter typing (5 changes) | Count replaced by key each time | Final count only |
| Same message twice | Cleared then set | Announced both times |
| Load error | Assertive | Interrupts current speech |
Integration context
Permalink to "Integration context"The general queue design is discussed in announcement queues for competing messages. Region creation timing is covered in creating live regions before content changes. Nuxt-specific route announcements build on this composable in Nuxt route announcements for data pages.
Gotchas
Permalink to "Gotchas"SSR. In Nuxt, render the regions in the root layout so they exist on first paint; module-level refs are shared across requests on the server, so create the state per app instance (for example with useState in Nuxt).
Teleports and portals. Do not teleport the regions into dialogs; keep them at the root and outside inert areas.
Testing. Expose the current messages for tests (polite.value), so components can be unit-tested for what they announce.
Common mistakes
Permalink to "Common mistakes"- Regions inside components that unmount.
- No clearing, so repeated messages are silent.
- Every message assertive.
- Announcing on every keystroke without keys or debounce.
Version and upgrade notes
Permalink to "Version and upgrade notes"The composable has no dependencies beyond Vue 3. If you migrate to Nuxt 3 or later, move module-level state into useState or a plugin to avoid cross-request sharing during server rendering.
Worked example
Permalink to "Worked example"A Vue analytics app had seven components each rendering their own aria-live div. Filters, sorting and chart updates fired within the same second and screen readers spoke an unpredictable subset. Replacing them with one root announcer, keys for each message type and a 250 ms debounce produced single, ordered messages (“Last 30 days. Sorted by revenue, descending. 18 products shown.”) and reduced announcement-related bug reports to zero.
Message keys that work well
Permalink to "Message keys that work well"Using consistent keys lets the queue merge and replace messages predictably across components: sort for ordering changes, count for result counts, page for pagination, selection for selection counts, load for loading and completion, and error for failures (assertive). Components announcing the same kind of fact use the same key, so a filter change followed by a page reset produces one coherent message rather than two competing ones. Document the keys alongside the composable so every team uses them.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Ship the announcer as part of the design system’s Vue package, with message keys documented (sort, count, page, load, error) so components across teams merge consistently.
FAQ
Permalink to "FAQ"Why not use a library like vue-announcer?
Libraries work well; the important parts are the same — root regions, a queue and clearing. Use one if it meets those needs.
How long should the debounce be?
200–500 ms for merging related messages; longer for typing-driven counts.
Should the announcer handle focus?
No. Focus management belongs in components; the announcer only speaks.
Can I test announcements?
Yes — read the region text or the composable’s refs in unit tests, and use speech logs for end-to-end checks.
What about Pinia?
You can implement the queue in a Pinia store instead of module state; the root regions read from it.
Should messages be translated?
Yes. Build messages with your i18n library, including plural rules.
How do I prevent announcements during initial load?
Skip announcements until after the first user interaction, or pass a source flag and announce only when the source is “user”.
Does the composable work with Options API components?
Yes. Call useAnnouncer in setup() or expose announce through a plugin as this.$announce.
Related
Permalink to "Related"- Nuxt route announcements — route-level messages
- Announcement queues — the general design
- Live regions in React — the React counterpart