Skip to content

feat(nuxt): add useAnnouncer composable and <NuxtAnnouncer> component#34318

Merged
danielroe merged 17 commits intonuxt:mainfrom
fabkho:a11y_announcer
Feb 25, 2026
Merged

feat(nuxt): add useAnnouncer composable and <NuxtAnnouncer> component#34318
danielroe merged 17 commits intonuxt:mainfrom
fabkho:a11y_announcer

Conversation

@fabkho
Copy link
Copy Markdown
Contributor

@fabkho fabkho commented Feb 12, 2026

🔗 Linked issue

Relates to #23255 (Accessibility Roadmap)
Follows the pattern established in #25741 (useRouteAnnouncer)

📚 Description

This PR adds a general-purpose announcer for screen readers, complementing the existing route announcer.

Why is this useful?

While useRouteAnnouncer handles page navigation announcements automatically, many apps need to announce dynamic in-page content changes that aren't tied to navigation:

<script setup lang="ts">
const { polite, assertive } = useAnnouncer()

async function submitForm() {
  try {
    await $fetch('/api/contact', { method: 'POST', body: formData })
    polite('Message sent successfully')  // Non-urgent success feedback
  } catch (error) {
    assertive('Error: Failed to send message')  // Urgent error notification
  }
}
</script>

Common use cases:

  • ✅ Form validation errors
  • ✅ Toast/notification messages
  • ✅ Loading states ("Loading...", "Data loaded")
  • ✅ Live search results ("Found 5 results")
  • ✅ Async operation feedback

To be fair, this composable shouldn't be overused. Most cases can be solved by just focusing the new content and maybe adding an aria-label for extra context. For example, in a checkout flow you could use useAnnouncer to announce success. But if you have a dedicated success page, just focusing the heading is already sufficient.

I'm actually using this in my own Nuxt app for a complex calendar. There's just no good spot to put an aria-live on, and focusing stuff on every change would be annoying. So the announcer works well here for date changes, event updates etc.

(Not sure if we should clarify this in the docs though)

What's included

Addition Description
useAnnouncer() Composable for manual announcements
<NuxtAnnouncer> Component that renders the live region
Documentation Full docs with examples
Tests Unit + E2E coverage

Usage

<!-- app.vue -->
<template>
  <NuxtAnnouncer />
  <NuxtRouteAnnouncer />
  <NuxtLayout>
    <NuxtPage />
  </NuxtLayout>
</template>
<!-- Any component -->
<script setup lang="ts">
const { polite, assertive } = useAnnouncer()

// Non-urgent (waits for screen reader to finish)
polite('Item added to cart')

// Urgent (interrupts immediately)
assertive('Session expired. Please log in again.')
</script>

Why separate singletons for useAnnouncer and useRouteAnnouncer?

Both composables maintain their own state (nuxtApp._announcer vs nuxtApp._routeAnnouncer). Yes, this means some code duplication, but it makes sure the route announcement is never overwritten by the announcer.

Open to discussion here (happy to refactor). But for now, this approach keeps things predictable.

@fabkho fabkho requested a review from danielroe as a code owner February 12, 2026 00:23
@bolt-new-by-stackblitz
Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds a screen‑reader announcer system: a new useAnnouncer composable and NuxtAnnouncer component, plus types (AnnouncerPoliteness, NuxtAnnouncer, NuxtAnnouncerOpts) and re‑exports. Registers the component, extends internal _NuxtApp with _announcer and _announcerDeps, and updates imports/presets. Documentation pages and examples were added; tests for polite/assertive announcements and lifecycle cleanup were included. Route announcer behaviour/signatures were adjusted (set politeness optional, useRouteAnnouncer signature changed) and live region role is now derived from politeness. No public exports were removed.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarises the main addition: a new useAnnouncer composable and NuxtAnnouncer component for screen reader announcements.
Description check ✅ Passed The description thoroughly explains the purpose, use cases, and implementation details of the new announcer feature, directly relating to the changeset across documentation, code, and tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Feb 12, 2026

Open in StackBlitz

@nuxt/kit

npm i https://pkg.pr.new/@nuxt/kit@34318

@nuxt/nitro-server

npm i https://pkg.pr.new/@nuxt/nitro-server@34318

nuxt

npm i https://pkg.pr.new/nuxt@34318

@nuxt/rspack-builder

npm i https://pkg.pr.new/@nuxt/rspack-builder@34318

@nuxt/schema

npm i https://pkg.pr.new/@nuxt/schema@34318

@nuxt/vite-builder

npm i https://pkg.pr.new/@nuxt/vite-builder@34318

@nuxt/webpack-builder

npm i https://pkg.pr.new/@nuxt/webpack-builder@34318

commit: e608925

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 17-20: The code passes props.politeness as a plain value to
useAnnouncer, so changes to the parent prop won't propagate; update setup to
pass a reactive reference instead (e.g., create a ref with toRef(props,
"politeness") or watch props.politeness and call set/update on the useAnnouncer
API) and ensure you import toRef (or watch) from 'vue' and use the reactive ref
when calling useAnnouncer or when updating its politeness option; target the
useAnnouncer call and the props.politeness usage to keep the composable in sync.
- Around line 35-37: The span currently hardcodes role="alert" while binding
aria-live to politeness.value, causing a conflict; change the role to be
computed from politeness.value (e.g. if politeness.value === 'assertive' use
'alert', otherwise use 'status' or omit role) and replace the literal 'role':
'alert' in the h('span', ...) props with that computed value (or remove role
when politeness is absent) so the role and aria-live are consistent; locate the
h('span', { 'role': 'alert', 'aria-live': politeness.value, ... }) occurrence in
nuxt-announcer.ts and update it to use the computedRole derived from
politeness.value.

In `@packages/nuxt/src/app/composables/announcer.ts`:
- Around line 25-28: The set function using shallowRef for message will not
re-trigger reactivity when the same primitive string is set twice; update the
set implementation (function set) to clear message.value briefly before
assigning the new message (e.g., set message.value = '' then await nextTick() or
use a zero-delay setTimeout) and then assign message.value = messageValue and
politeness.value = politenessSetting so identical consecutive messages are
re-announced by the aria-live region.

In `@test/nuxt/composables.test.ts`:
- Around line 922-926: The test description claims it verifies the default
politeness but calls useAnnouncer with an explicit { politeness: 'polite' };
change the test to call useAnnouncer() with no arguments so it exercises the
default behavior (replace useAnnouncer({ politeness: 'polite' }) with
useAnnouncer()), then assert announcer.politeness.value === 'polite' to validate
the default; keep the test name and expectations otherwise unchanged.
🧹 Nitpick comments (1)
packages/nuxt/src/app/composables/announcer.ts (1)

66-70: Last-caller-wins semantics for the default politeness option.

When multiple components call useAnnouncer({ politeness: 'assertive' }) and useAnnouncer({ politeness: 'polite' }), the second call silently overwrites the first's default. This is consistent with the singleton design, but it may surprise consumers.

Consider either documenting this behaviour explicitly or only allowing the initial caller to set the default (i.e. skip the override for subsequent calls).

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq bot commented Feb 12, 2026

Merging this PR will not alter performance

✅ 20 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing fabkho:a11y_announcer (e608925) with main (297e4e5)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/nuxt/src/app/components/nuxt-announcer.ts (1)

17-20: Props are not reactive when passed to useAnnouncer.

props.politeness is read once as a plain value at setup time. If the parent dynamically changes the politeness prop, the composable's internal state won't update. Consider watching the prop:

♻️ Suggested fix
+import { computed, defineComponent, h, watch } from 'vue'
-import { computed, defineComponent, h } from 'vue'
 import type { AnnouncerPoliteness } from '../composables/announcer'
 import { useAnnouncer } from '../composables/announcer'
 ...
   setup (props, { slots, expose }) {
     const { set, polite, assertive, message, politeness } = useAnnouncer({
       politeness: props.politeness,
     })
+
+    watch(() => props.politeness, (val) => {
+      politeness.value = val
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxt/src/app/components/nuxt-announcer.ts` around lines 17 - 20, The
setup currently passes props.politeness as a plain value into useAnnouncer, so
downstream state in useAnnouncer won’t update when the parent changes
politeness; update setup to pass a reactive reference (e.g., toRef(props,
'politeness') or a watched wrapper) into useAnnouncer or add a watch on
props.politeness that calls the composable’s setter (setPoliteness / set or
similar) so the composable (useAnnouncer) receives updates; locate the setup
function and the useAnnouncer call to implement the reactive prop passing or
watch.
🧹 Nitpick comments (2)
test/nuxt/composables.test.ts (1)

957-959: Unnecessary as any cast on _announcerDeps.

Both _announcer and _announcerDeps are declared as optional properties on the _NuxtApp interface (see nuxt.ts lines 194–197), so delete should work on both without casting. The as any on line 959 is inconsistent with line 958.

♻️ Suggested fix
     // Clean up any pre-existing announcer state
     delete nuxtApp._announcer
-    delete (nuxtApp as any)._announcerDeps
+    delete nuxtApp._announcerDeps
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/nuxt/composables.test.ts` around lines 957 - 959, The delete on
nuxtApp._announcerDeps uses an unnecessary and inconsistent cast; remove the `as
any` and delete the property directly like the `_announcer` cleanup. Locate the
cleanup block around nuxtApp and replace `delete (nuxtApp as
any)._announcerDeps` with a plain `delete nuxtApp._announcerDeps`, relying on
the optional `_announcerDeps` declaration on the _NuxtApp interface.
packages/nuxt/src/app/components/nuxt-announcer.ts (1)

32-54: Outer wrapper <span> may need aria-hidden or be semantically inert.

The outer <span class="nuxt-announcer"> with only position: absolute is not visually hidden itself — only the inner span carries the sr-only styles. While this doesn't break anything, the outer span could be simplified. More importantly, if you ever need multiple children inside it, the current single-child render structure works but is fragile.

This is a minor observation — the current structure is functional.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/nuxt/src/app/components/nuxt-announcer.ts` around lines 32 - 54, The
outer wrapper span created in the render return (the element with class
"nuxt-announcer") should be made semantically inert to avoid exposing it to
assistive tech; update that element's props to include either 'aria-hidden':
'true' or role: 'presentation' (while keeping the inner sr-only span with
aria-live/aria-atomic intact), and ensure the render still uses slots.default ?
slots.default({ message: message.value }) : message.value for the inner content
so the announcer remains functional.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 37-39: The component currently sets role.value alongside
politeness.value, which makes role('status') conflict when politeness is 'off';
change the computed role (symbol: role or its getter in nuxt-announcer.ts, and
the AnnouncerPoliteness type usage) to explicitly handle the 'off' case by
returning undefined/null (or mapping to a neutral value like 'log') instead of
'status', and update the render call (the h('span', { 'role': role.value,
'aria-live': politeness.value })) to only add the role attribute when role is
defined so that aria-live="off" is not paired with role="status".

In `@packages/nuxt/src/app/components/nuxt-route-announcer.ts`:
- Line 20: The computed role mapping for nuxt-route-announcer.ts incorrectly
maps politeness.value === 'off' to 'status', conflicting with aria-live="off";
update the computed role (the role constant defined via computed(() =>
politeness.value === 'assertive' ? 'alert' : 'status')) to explicitly handle
'off' by returning undefined or an empty string when politeness.value === 'off',
mirroring the change made in nuxt-announcer.ts so aria-live and role stay
consistent (use politeness.value checks in the computed to return 'alert' for
'assertive', 'status' for 'polite', and undefined/'' for 'off').

---

Duplicate comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 17-20: The setup currently passes props.politeness as a plain
value into useAnnouncer, so downstream state in useAnnouncer won’t update when
the parent changes politeness; update setup to pass a reactive reference (e.g.,
toRef(props, 'politeness') or a watched wrapper) into useAnnouncer or add a
watch on props.politeness that calls the composable’s setter (setPoliteness /
set or similar) so the composable (useAnnouncer) receives updates; locate the
setup function and the useAnnouncer call to implement the reactive prop passing
or watch.

---

Nitpick comments:
In `@packages/nuxt/src/app/components/nuxt-announcer.ts`:
- Around line 32-54: The outer wrapper span created in the render return (the
element with class "nuxt-announcer") should be made semantically inert to avoid
exposing it to assistive tech; update that element's props to include either
'aria-hidden': 'true' or role: 'presentation' (while keeping the inner sr-only
span with aria-live/aria-atomic intact), and ensure the render still uses
slots.default ? slots.default({ message: message.value }) : message.value for
the inner content so the announcer remains functional.

In `@test/nuxt/composables.test.ts`:
- Around line 957-959: The delete on nuxtApp._announcerDeps uses an unnecessary
and inconsistent cast; remove the `as any` and delete the property directly like
the `_announcer` cleanup. Locate the cleanup block around nuxtApp and replace
`delete (nuxtApp as any)._announcerDeps` with a plain `delete
nuxtApp._announcerDeps`, relying on the optional `_announcerDeps` declaration on
the _NuxtApp interface.

ℹ️ Review info

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 335f95a and 1b97f8f.

📒 Files selected for processing (5)
  • packages/nuxt/src/app/components/nuxt-announcer.ts
  • packages/nuxt/src/app/components/nuxt-route-announcer.ts
  • packages/nuxt/src/app/nuxt.ts
  • test/basic.test.ts
  • test/nuxt/composables.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/basic.test.ts

@danielroe danielroe enabled auto-merge February 24, 2026 21:36
@danielroe danielroe added this pull request to the merge queue Feb 24, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Feb 24, 2026
@danielroe danielroe added this pull request to the merge queue Feb 24, 2026
@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks Feb 24, 2026
@danielroe danielroe added this pull request to the merge queue Feb 25, 2026
Merged via the queue into nuxt:main with commit 2e04cf1 Feb 25, 2026
56 of 57 checks passed
@github-actions github-actions bot mentioned this pull request Feb 25, 2026
This was referenced Mar 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants