The Wrapper That Stays While the Content Remounts
In PrismHero.tsx, the answer panel updates every time a user picks a new question. We want two things to happen at once: a CSS entrance animation on the new answer, and a screen reader announcement of the new content.
The CSS animation is straightforward. Put the animation on the answer div and use key={active} to force React to unmount the old node and mount a fresh one on each question change. React treats a changed key as a completely new element, so the browser restarts the animation from scratch.
The screen reader announcement is where the order of operations matters.
<div aria-live="polite" aria-atomic="true">
{active !== null && (
<div className="prism-answer" key={active}>
<h2>{currentQuestion.title}</h2>
<p>{currentQuestion.answer}</p>
</div>
)}
</div>
The aria-live attribute is on the outer div, not on the .prism-answer div that carries key. That placement is intentional.
Screen readers implement aria-live by attaching a mutation observer to the marked node. The observer fires when the node's subtree changes, then announces whatever the region now contains. The catch: if the aria-live node itself is replaced (because it carries a key that changes), the mutation observer is attached to a node that just got thrown away. The new node is a stranger to the observer. The announcement may be missed.
By keeping aria-live on a stable wrapper that never remounts, the mutation observer survives every key change. When React replaces the inner .prism-answer node, the observer sees a subtree mutation on its persistent target and fires. The old node disappears, a new one appears, and the region announces.
aria-atomic="true" is the second half. Without it, a mutation observer can report only the changed portions, which for a fresh mount is the entire tree. Different screen readers handle partial announcements differently. With aria-atomic="true", the entire region is read as a unit on any mutation: the answer heading and the full paragraph text, in one announcement. That matches the visual experience, where the whole answer panel appears at once.
The conditional {active !== null && ...} also matters. When the user resets the demo, the inner div disappears, leaving the aria-live wrapper empty. Screen readers observe that removal too. Some would announce the now-empty region. They don't, because nothing is left to read. The empty region is effectively silent.
The nesting looks redundant at first. A single div with both aria-live and key would seem simpler. But those two attributes pull in opposite directions: key makes React replace the node, while aria-live needs the node to persist. Separating them, one attribute per layer, resolves the conflict.
This wrapper pattern applies anywhere you combine CSS animation remounting with live region announcements. Put aria-live on the container that holds the animation target. Let the container persist through every content swap. The mutation observer stays attached, and the announcement fires every time.