Announcing transaction status to screen readers
How to use role=status, role=alert and aria-live to announce pending, confirmed and failed transactions under WCAG 4.1.3 without flooding users.
5 min read
A blockchain transaction plays out over seconds or minutes, and most of it happens while the person is looking somewhere else. They approve in a wallet window, return to the dApp, and wait. Sighted users watch a toast change from "Pending" to "Confirmed". Screen reader users hear nothing unless the page announces it, and they may never find the toast at all.
The tools for this are live regions. Used well, they tell people what happened at each step. Used carelessly, they read out every block confirmation and timer tick until people turn the sound off.
What WCAG asks
4.1.3 Status Messages (AA), added in WCAG 2.1, says that status messages must be exposed through a role or properties so assistive technologies can present them without moving focus. WCAG defines a status message as a change in content that tells the user about the result of an action, a waiting state, the progress of a process, or an error, without changing the context.
"Swap submitted", "Waiting for confirmation" and "Transaction failed" are all status messages by that definition. The criterion doesn't require you to announce them in a particular way, only that they are marked up so a screen reader can announce them. In practice that means live regions.
The live region toolkit
| Markup | Behavior | Use it for |
|---|---|---|
role="status" |
polite: waits until the screen reader finishes what it's saying; implies aria-atomic="true" |
progress and success messages |
role="alert" |
assertive: may interrupt current speech; implies aria-atomic="true" |
failures that need attention now |
aria-live="polite" |
polite, without the status semantics | custom regions where you need finer control |
role="log" |
polite, for content that grows over time | chat or activity feeds, used sparingly |
One rule is easy to miss: the region has to exist before its content changes. Screen readers watch live regions for changes. A toast component that mounts already containing "Swap confirmed" is often not announced, because there was no change to observe. Render empty regions once, when the app loads, and write text into them later.
Use assertive announcements sparingly. Interrupting someone mid-sentence is justified when a transaction failed. It isn't justified for "Submitted".
Map each state to one message
Decide up front what gets said at each step. For a typical swap:
| Moment | Message | Region |
|---|---|---|
| Wallet prompt opens | Confirm the swap in your wallet. | status |
| User declines in the wallet | Swap canceled in your wallet. | status |
| Transaction broadcast | Swap submitted. Waiting for confirmation. | status |
| Each new block or confirmation | nothing | none |
| Confirmed | Swap confirmed. You received 3,118.20 USDC. | status |
| Reverted | Swap failed. No tokens were swapped, and the network fee was still charged. | alert |
| Sped up (replacement sent) | Swap sped up. Waiting for confirmation. | status |
| Dropped | The swap was dropped and did not go through. | alert |
A few principles show up in that table. Each message says what happened and what it means for the user, not just the state name. A declined request is the user's own choice, so it is reported calmly and politely. A reverted transaction gets the alert, because something went wrong that the user didn't choose, and it says plainly that fees were still spent, which is true for reverted transactions on Ethereum and other EVM chains. For writing these messages, see writing error messages for dApps.
One shared announcer
Instead of giving every toast its own live region, mount one polite and one assertive region at the root of the app and send all announcements through them:
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
type Announce = (message: string, urgent?: boolean) => void
const AnnounceContext = createContext<Announce>(() => {})
export function AnnouncerProvider({ children }: { children: ReactNode }) {
const [politeText, setPoliteText] = useState('')
const [urgentText, setUrgentText] = useState('')
const announce = useCallback<Announce>((message, urgent = false) => {
const set = urgent ? setUrgentText : setPoliteText
set('')
setTimeout(() => set(message), 50)
}, [])
return (
<AnnounceContext.Provider value={announce}>
{children}
<div role="status" className="sr-only">{politeText}</div>
<div role="alert" className="sr-only">{urgentText}</div>
</AnnounceContext.Provider>
)
}
export const useAnnounce = () => useContext(AnnounceContext)
Clearing the region and writing the message a moment later means that the same text twice in a row, such as two swaps that both confirm, still counts as a change and gets announced.
Then the transaction code calls announce at each step:
const announce = useAnnounce()
async function submitSwap() {
announce('Confirm the swap in your wallet.')
let hash: string
try {
hash = await sendSwap()
} catch (error) {
const declined = (error as { code?: number }).code === 4001
announce(declined ? 'Swap canceled in your wallet.' : 'The swap could not be sent.', !declined)
return
}
announce('Swap submitted. Waiting for confirmation.')
const result = await waitForResult(hash)
if (result.ok) {
announce(`Swap confirmed. You received ${result.received}.`)
} else {
announce('Swap failed. No tokens were swapped, and the network fee was still charged.', true)
}
}
sendSwap and waitForResult stand in for whatever your wallet library provides. Error code 4001 is the user-rejected error defined in EIP-1193.
Things that flood users
Over-announcing usually comes from a few patterns:
- Counters. "3 of 12 confirmations", "Pending for 14 s". If these sit inside a live region, every update is spoken. Show them visually, outside any region.
- Animated text. A "Pending..." label whose dots are added one at a time by script changes the text every few hundred milliseconds, and each change can be announced. Keep the animation out of the region, or hide it with
aria-hidden="true". - Polling. Code that checks the transaction every few seconds and calls
announce('Pending')each time repeats the same news. Announce changes in state, not checks. - Double announcements. If a toast has
role="status"and you also call the shared announcer, people hear everything twice. Pick one channel. - Simultaneous completions. When several transactions confirm in the same block, one message ("3 transactions confirmed") is better than three in a row.
Keep messages short. People can't skim speech, and a 40-word status message holds up everything else they are trying to do.
Beyond the announcement
Live regions are fleeting. Someone might miss a message, or want to hear it again, so the information also needs a permanent home: a recent activity list where each transaction shows its state as text, not only as a colored dot or icon. Pending, confirmed, failed covers how to design those states visually.
Don't move focus to a toast to make it heard. 4.1.3 exists so status can be announced without taking focus away from what the person is doing. The exception is a failure that blocks the flow and needs a decision, such as a quote that expired before signing. That can justify a dialog, which then manages focus properly, as described in making wallet connection modals accessible.
To check your work, turn on a screen reader, run a swap from start to finish without looking, and count the announcements. A successful swap should take three or four, each one telling you something new.