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" and role="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.
How the announcer is structured Layers: components call announce(); the queue merges and debounces by key; the announcer writes to one of two persistent regions in the app root; screen readers speak. How the announcer is structuredComponentstables, filters, loaders call announce()Queuemerge by key; debounce; drop superseded messagesRegionsone polite (status) and one assertive (alert) at app rootScreen readerspeaks messages in order
One place owns timing and politeness for the whole app.

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.

Per-component regions versus one announcer Comparison: per-component regions are created late, collide and duplicate; one root announcer exists from load, queues messages and handles repetition. Per-component regions versus one announcer✗ Region in every componentCreated with the component; often silentMessages collideIdentical messages swallowedPoliteness chosen ad hocHard to test✓ One announcer at the rootPresent from page loadQueued and orderedCleared and re-setPoliteness by message kindOne place to assert messages

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
A sort that also changes the count Sequence among table, announcer queue and live region: the table calls announce for sort and for count; the queue merges them under different keys; after the debounce the polite region receives one combined message. A sort that also changes the countTableQueuePolite regionannounce("Sorted by Amount…", keysort)announce("240 invoices shown", keycount)debounce 250 ms; merge"Sorted by Amount, ascending. 240invoices shown."
Two calls, one coherent announcement.

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.

Permalink to "Related"

← Back to Vue & Nuxt Data Components