web3icons

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.

More in Accessibility

All guides