Skip to content

Announcing to Screen Readers: AnnouncerOutlet and the aria-live Portal

1/5/2026Food & CookingKUIreact6 min read

Announcing to Screen Readers: AnnouncerOutlet and the aria-live Portal

"3 results found." How does a screen reader user know this? aria-live regions — but implementing them correctly is harder than it looks.

Imagine a filtering interface. The user types "london," the list updates. Sighted users see the update. Screen reader users hear nothing — unless aria-live regions are in place.

kui-react's AccessibilityKit solves this with AnnouncerOutlet: a portal-mounted aria-live region, a module-level listener set, and an imperative useAnnounce() hook. Three layers, one clean interface.

Why a Portal?

// ❌ Wrong — inside overflow: hidden
function SearchResults() {
  return (
    <div className="overflow-hidden max-h-96">
      <div role="status" aria-live="polite">
        {announcement}
      </div>
      {results.map(/* ... */)}
    </div>
  );
}

While CSS can't clip aria-live regions from being announced, some screen readers behave unexpectedly with regions inside overflow: hidden or position: fixed containers. The larger problem: if multiple components want to announce simultaneously, which region do they write to?

The solution: one global aria-live region, mounted directly on document.body.

// modules/app/AccessibilityKit.tsx
export function AnnouncerOutlet() {
  const [polite, setPolite] = useState('');
  const [assertive, setAssertive] = useState('');

  useEffect(() => {
    const handler = (msg: AnnounceQueue) => {
      if (msg.priority === 'assertive') {
        setAssertive('');
        setTimeout(() => setAssertive(msg.text), 16);
      } else {
        setPolite('');
        setTimeout(() => setPolite(msg.text), 16);
      }
    };

    listeners.add(handler);
    return () => listeners.delete(handler);
  }, []);

  return createPortal(
    <>
      <span
        role="status"
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {polite}
      </span>
      <span
        role="alert"
        aria-live="assertive"
        aria-atomic="true"
        className="sr-only"
      >
        {assertive}
      </span>
    </>,
    document.body,
  );
}

createPortal(children, document.body) — independent of the React tree, a direct child of body in the DOM. Overflow, z-index, transform — nothing can interfere.

The 16ms Clear → Set Trick

setPolite('');                              // clear first
setTimeout(() => setPolite(msg.text), 16); // then set

Why clear before setting?

Screen readers announce changes to aria-live regions. If the region already says "3 results found" and you set it to "3 results found" again — no content change, no announcement. The screen reader stays silent.

setState('') + setTimeout(16) creates a gap between two renders. The screen reader sees an empty string, then on the next frame sees the new text — and announces it. 16ms is one render frame.

This is a workaround for screen reader implementation behavior, not a spec requirement. NVDA and VoiceOver both handle it correctly at 16ms — it's the smallest value that works across both.

Module-Level Listener Set

type Listener = (queue: AnnounceQueue) => void;
const listeners = new Set<Listener>();

interface AnnounceQueue {
  text: string;
  priority: 'polite' | 'assertive';
}

No Context, no Zustand. listeners is a module singleton. AnnouncerOutlet adds its handler to this set. useAnnounce() sends messages through this set.

export function useAnnounce() {
  return useCallback((text: string, priority: 'polite' | 'assertive' = 'polite') => {
    listeners.forEach((l) => l({ text, priority }));
  }, []);
}

Stable reference via useCallback — no new function created on each render.

function SearchBar() {
  const announce = useAnnounce();

  const handleSearch = (query: string) => {
    const results = filterResults(query);
    announce(`${results.length} results found`);
  };

  return <input onChange={(e) => handleSearch(e.target.value)} />;
}

Two Priority Levels: Polite vs Assertive

<span role="status" aria-live="polite">
  {polite}
</span>
<span role="alert" aria-live="assertive">
  {assertive}
</span>

aria-live="polite" — waits until the user finishes their current interaction, then announces. Search results, page transitions, form validation feedback.

aria-live="assertive" — interrupts immediately, regardless of what the user is doing. Critical errors, session timeouts, authentication failures.

Use assertive sparingly. Interrupting the user's reading or navigation flow is disruptive. Most announcements should be polite.

aria-atomic="true" — announce the full region content, not just the changed part. "3 results" is more useful than just "3."

Application Integration

// app/layout.tsx (Next.js)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <AnnouncerOutlet />
        <SkipLink href="#main-content" />
        <main id="main-content">
          {children}
        </main>
      </body>
    </html>
  );
}

AnnouncerOutlet can be placed anywhere — the createPortal(document.body) call moves it to the end of body regardless of where it appears in the React tree.

Trade-off

Module-level singleton can leak between requests in SSR. A server component can't call useAnnounce() (it's a hook). The correct approach: keep AnnouncerOutlet and all callers of useAnnounce() in client components. In Next.js App Router with 'use client' on the relevant components, this is the natural setup.

The 16ms timeout workaround needs testing across screen reader / browser combinations. JAWS + Chrome, NVDA + Firefox, VoiceOver + Safari can all behave differently. 16ms works in most combinations; some may need 32ms.

Multiple AnnouncerOutlet instances cause double announcements. One instance per application, in the root layout.

Business Impact

In Turkey, KVKK-governed public services and across Europe under the EAA (European Accessibility Act, effective 2025), web accessibility is a legal requirement for many services. Screen reader support is no longer optional for regulated applications.

More practically: 26% of users have some form of temporary or permanent motor, visual, or hearing difference. This includes people who can't see the screen in bright sunlight, people holding a child, or people driving and using voice control.

useAnnounce() adds one line to tell screen reader users what your application is doing — for every dynamic update that changes the page state.

Something to Try

Add these three things to your application:

  1. A role="status" aria-live="polite" span mounted via createPortal on document.body
  2. A useAnnounce() hook that sets the span's content with the 16ms clear+set pattern
  3. On your most-used form: announce('Form saved successfully') on submit

Then open VoiceOver (Mac: ⌘+F5) or NVDA (Windows: free download) and use that form. When you hear "Form saved successfully" announced without any focus change, you understand what screen reader users experience — and what they're missing when it's absent.

Related Articles

Same Category

Comments (0)

Newsletter

Stay updated! Get all the latest and greatest posts delivered straight to your inbox