Screen-reader announcements
StableuseAnnounce makes screen readers say a result like “Moved to Done”, politely or, for urgent things, at once. Nothing shows on screen.
AnnouncerProvider puts two hidden live regions on the page from the first render, and useAnnounce returns a function that writes into them. Use it when something changes that a screen reader user would otherwise miss: a row moved, a list loaded, a timer ended. This site wraps every page in AnnouncerProvider; an app needs to do that once, near its root.
Polite and assertive
import { useState } from 'react'import { useAnnounce } from '@rchursin/product/lib/announce'import { Button } from '@rchursin/product/ui'
type Heard = { id: number; text: string; politeness: 'polite' | 'assertive' }
/** * Results are polite: they wait for the screen reader to finish. Assertive is only for things * that need attention now. The list underneath mirrors what a screen reader is given, so you can * see it; press the same button twice and the sentence is still read again. */export default function AnnouncerBasic() { const announce = useAnnounce() const [done, setDone] = useState(false) const [heard, setHeard] = useState<Heard[]>([])
function say(text: string, politeness: Heard['politeness'] = 'polite') { announce(text, politeness) setHeard((all) => [{ id: (all[0]?.id ?? 0) + 1, text, politeness }, ...all].slice(0, 3)) }Usage
import { AnnouncerProvider, useAnnounce } from '@rchursin/product/lib/announce'// Once, near the root<AnnouncerProvider><App /></AnnouncerProvider>
// Anywhere insideconst announce = useAnnounce()announce('Moved to Done')announce('Focus session finished', 'assertive')API
announce(message, politeness?)
| Prop | Type | Default | Description |
|---|---|---|---|
messagerequired | string | – | What to say. Short, and past tense for results. |
politeness | 'polite' | 'assertive' | 'polite' | Polite waits for the screen reader to finish. Assertive interrupts: only for things that need attention now. |
AnnouncerProvider props
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | – | Your app. The two live regions render after it. |
Accessibility
- Two visually hidden regions are on the page from the first render: role="status" (polite) and role="alert" (assertive), both aria-atomic. Regions added later are often missed, which is why the provider owns them.
- Each call empties its region and writes the message 60 ms later, so the same sentence twice is still read twice.
- Calls less than 60 ms apart share one timer, so only the last is read. Put things that happen together into one sentence.
- Without the provider, useAnnounce returns a function that does nothing.
- Nothing appears on screen, so make sure the change is visible too.
Guidelines
Do
- Announce results, not steps: “3 runs loaded”, not “Loading”.
- Keep it to one short sentence.
- Use assertive only when the person has to act now.
Don’t
- Don’t announce what moving focus already reads out.
- Don’t announce on every keystroke or every tick of a clock.
- Don’t add your own aria-live regions for the same message; use the shared ones.
In use
Used on this site
Related
- ToastShort past-tense results with at most one action, like “Run archived · Undo”. Read out politely; they wait while hovered or focused.
- BannersIn-page messages for things that happen on their own: a timer that finished, a reminder that came due.
- Error summaryA “There is a problem” box at the top of a form that takes focus after a failed submit and links each message to its field.
- StopwatchA stopwatch with laps: a square that fills once a minute, hundredths drawn per frame, and Undo after a reset.
Source
The component’s own files, exactly as they run on this site.
/** * Screen-reader announcements. Two visually hidden live regions exist from the first render * (regions added later are often ignored). Use polite for results ("Task moved"), * assertive only for things that block the user ("Timer finished"). */import { createContext, useCallback, useContext, useRef, useState, type ReactNode } from "react"
type Politeness = "polite" | "assertive"const AnnounceContext = createContext<(message: string, politeness?: Politeness) => void>(() => {})
export function AnnouncerProvider({ children }: { children: ReactNode }) { const [polite, setPolite] = useState("") const [assertive, setAssertive] = useState("") const timer = useRef<number | undefined>(undefined) const announce = useCallback((message: string, politeness: Politeness = "polite") => { const set = politeness === "assertive" ? setAssertive : setPolite // Clear first so the same sentence twice is still read set("") window.clearTimeout(timer.current) timer.current = window.setTimeout(() => set(message), 60)