Our quiz app shows the same countdown twice: once on the host's screen behind the bar, and once on every player's phone. Same clock, same seconds, two separate components written months apart. One of them talks to a screen reader every single second. The other says nothing at all.
Neither is right, and the pair of them is a decent illustration of what live regions are for.
The host's countdown
<span
className="tabular-nums text-4xl font-bold transition-colors duration-300 ..."
role="timer"
aria-live="polite"
aria-label={`${remaining} seconds remaining`}
>
{remaining}
</span>
remaining is recomputed from the server clock on a one second interval, so both the text node and the aria-label are replaced on every tick, for the whole answer window.
Now read the ARIA specification on the role in that markup:
Elements with the role
timerhave an implicitaria-livevalue ofoff.
Its superclass is status, and status is the opposite:
Elements with the role
statushave an implicitaria-livevalue ofpoliteand an implicitaria-atomicvalue oftrue.
So ARIA has a live region role whose whole job is to carry a counter that changes constantly, and the working group gave that one role, alone among the live region roles, a default of "do not announce this". The default is the advice. A thing that updates every second is a thing you have to opt into hearing, because the alternative is a voice counting over the top of whatever the user is actually trying to read.
Our component opts in. For a fifteen second question, that is an announcement queued every second while the player is trying to hear four answer options read out.
The part I cannot tell you from the code
There is a second problem in those four lines, and it is more interesting than the first because it is not decidable by reading them.
The element has an aria-label, and timer takes its name from the author, so the label is the accessible name. The label changes every second. The text content also changes every second. Which of those two drives the announcement, and whether a changing name counts as a live region update at all, is down to the individual screen reader and browser pairing.
I am not going to tell you what JAWS, NVDA and VoiceOver each do here, because I have not tested all three, and a blog post that guesses at assistive technology behaviour is worse than one that admits the gap. What I will say is that needing the test at all is the argument against the markup. A live region whose content and whose accessible name both change on the same tick is ambiguous by construction. The fix removes the ambiguity rather than resolving it.
The player's countdown, which has the opposite bug
The same number on a player's phone, in a different component:
<span className={`flex items-center gap-1 font-bold tabular-nums ...`}>
<Clock className="h-3.5 w-3.5" />
{timer}s
</span>
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
<motion.div
className={`h-full rounded-full ${timerBarColor} ...`}
animate={{ width: `${timerPct}%` }}
transition={{ duration: 1, ease: 'linear' }}
/>
</div>
No role, no aria-live, no label. The bar is a div whose width is animated between renders, with no role="progressbar" and no aria-valuenow, so the one element on the screen that encodes "how much time is left" is, to the accessibility tree, decoration with no text in it.
A player using a screen reader gets the question, gets the options, and gets no indication whatsoever that there is a clock, let alone where it has got to. On the host's screen the clock will not stop talking. On the player's screen the clock does not exist. Same product, same sprint of work, opposite failures, and the reason is simply that two people-shaped surfaces were built by one person on two different days.
What it should be
The rule I would write down for any countdown:
Announce milestones, not ticks. The user does not need fifteen numbers, they need to know when the window is closing. Keep role="timer" and let its default aria-live: off stand, then push the two or three moments that matter into a separate polite region:
<span role="timer" aria-label={`${remaining} seconds remaining`}>{remaining}</span>
<span className="sr-only" aria-live="polite">
{remaining === 10 || remaining === 5 ? `${remaining} seconds left` : ''}
</span>
The timer is still in the accessibility tree, so anyone who goes looking for it can read the current value on demand. The announcements drop from fifteen to two. And the player's bar gets aria-hidden="true" with the same sr-only region beside it, because the bar duplicates a number that is already in the DOM.
The timings this is announcing, by the way, are the ones our question timer page describes for players: three seconds to read the question before the clock starts, five to fifteen seconds of answer window, and one second of grace at the buzzer. Those are constants shared between the client and the server rather than numbers each side guesses at, which is why the host's display and the server's auto-close agree to the second.
The whole live region inventory, since it is small enough to list
Sixteen lines of markup across four components are the entire announcement surface of a real-time game:
| Where | What | Why it exists |
|---|---|---|
| Question timer (host) |
role="timer" plus aria-live="polite", twice |
The subject of this post |
| Host overlays | one role="alert" with aria-live="assertive", two role="status" with polite
|
Full screen "launching", "ending session", error states |
| Host inline errors | five role="alert" paragraphs |
Launch, close, next question, end session failures |
| Player | two role="alert" blocks |
Connection lost, answer submission failed |
| Player lobby | one aria-live="polite" line, one role="alert"
|
"Checking for the quiz automatically", and the join error |
Which leaves the omission that bothers me more than the timer. The leaderboard, the thing the entire room is watching, is an <ol aria-label="Leaderboard"> whose rows reorder with an animation and which announces nothing:
<ol className="w-full space-y-1" aria-label="Leaderboard">
Our own page about it makes the case that the point of a live leaderboard is a table seeing, between question four and question five, that they have gone from third to second. That event, the one moment in the product genuinely worth announcing, currently reaches a sighted player through a row sliding up the screen and reaches everybody else not at all. One polite region containing "You are now second, 34 points" at each close would be a better use of the budget than a voice counting down from fifteen.
Because that is what a live region is: a budget. Every announcement spends the same channel the user is trying to listen to the content with. We were spending fifteen of them per question on a number, and zero on the only thing anyone in the pub cares about.
Top comments (0)