Live regions in React

Screen readers announce content as it receives focus. Certain user interactions which prompt an action may also shift focus. For example, when a dialog appears asking the user to respond, focus is transferred into the dialog. The element that triggers this behavior includes the aria-haspopup attribute, which signals to the user that activating it will move focus.

There are cases where notifications appear in the UI without moving the user’s focus. In such situations, the notification is visible on screen but not announced by screen readers. To ensure an equivalent experience for all users, live regions can be used to communicate important updates. However, it’s important to avoid announcing every change on a page. The goal is to strike the right balance—neither over-communicating nor under-communicating—so that users are kept informed of meaningful changes and feel confident using the web application.

Mirroring visual content into a live region

Most content should be both seen and heard. The primary goal was to ensure that visual notifications could also be announced by mirroring their content into a live region. To achieve this, I designed a component to create at most two live regions: one for polite announcements and another for assertive ones. For broad screen reader and browser compatibility, each region would include both aria-live and role attributes, paired correctly.

From a developer experience standpoint, the idea was to wrap any notification in an Announce React component, which would portal the content into the appropriate live region. To avoid requiring manual setup of live regions, the component would automatically create the live regions on first use, and they would persist to handle future notifications.

Another challenge was context awareness: ensuring that notifications did not announce while a tab or window was hidden. To handle this, the Page Visibility API was integrated, allowing notifications to be silenced in those cases.

Announce component anatomy

The basic structure of the React component (before full implementation) was defined as follows.

const Announce = React.forwardRef<HTMLDivElement, AnnounceProps>(
({ children, type = 'polite', role = ROLES[type], ...props },ref) =>{
const [liveRegion, setLiveRegion] = React.useState<HTMLElement>();
const getLiveRegionElement = React.useCallback(() => {
// Only create if existing region is not found. We don't want a
// live region created for every Announce on a page. Ideally we
// want a max of two live regions for polite and assertive
// announcements. In the future, we can add support for an
// optional identifier to create > 2 regions if that's needed.
}, [role, type]);
React.useLayoutEffect(() => {
// set live region, creating one if doesn't already exist
setLiveRegion(getLiveRegionElement());
}, [getLiveRegionElement]);
React.useEffect(() => {
// Use the Page Visibility API to silence announcements when
// the document is hidden.
}, [getLiveRegionElement, role, type]);
return (
<>
<div {...props} ref={ref}>
{children}
</div>
{/* portal into live region for screen reader announcements */}
{liveRegion &&
<AnnouncePortal liveRegion={liveRegion}>
{children}
</AnnouncePortal>}
</>
);
}
);

AnnouncePortal component anatomy

When creating the live regions for the first time, it was necessary to introduce a delay between appending the region to the DOM and appending content into it. Without this, the behavior was inconsistent. The solution I adopted was to queue a callback on the next animation frame (via requestAnimationFrame), which updated internal state and then allowed the content to be portal’d. With this approach, testing across different screen reader and browser combinations produced reliable results.

// Requests the browser to call a user-supplied callback
// function on the next frame.
export function useNextFrame(callback = noop) {
const fn = useCallbackRef(callback);
React.useLayoutEffect(() => {
let raf1 = 0;
let raf2 = 0;
raf1 = window.requestAnimationFrame(() =>
(raf2 = window.requestAnimationFrame(fn))
);
return () => {
window.cancelAnimationFrame(raf1);
window.cancelAnimationFrame(raf2);
};
}, [fn]);
}
const AnnouncePortal = ({
children,
liveRegion,
...props
}: AnnouncePortalProps) => {
const [announceText, setAnnounceText] = React.useState(false);
useNextFrame(() => setAnnounceText(true));
return (
<>
{announceText &&
ReactDOM.createPortal(
<div {...props}>{children}</div>,
liveRegion
)}
</>
);
};

Invisible live regions

Although less common, it can be useful to announce supplementary aural information that doesn’t always have a direct visual counterpart. For example, an autocomplete control might need to announce suggestions without requiring the user to explore the menu; a stepper component could announce updated values when increment or decrement buttons are pressed, without moving focus; or a loading spinner might benefit from a visually hidden live region that announces 'Loading products' to convey progress.

To support this, I created a useAnnounce hook. Unlike the Announce component, it tears down and recreates a live region each time an announcement is made. This better enables the same announcement to be made multiple times. Its basic structure was:

function useAnnounce(props: UseAnnounceProps = {}) {
const { delay = 500, type = 'polite' } = props;
const role = ROLES[type];
const [liveRegion, setLiveRegion] = useState<HTMLElement | null>(null);
const [message, setMessage] = useState<string | null>(null);
useLayoutEffect(
() => () => {
// Clean up the live region node on unmount.
liveRegion?.remove();
},
[liveRegion]
);
// create the live region each time
useLayoutEffect(() => {
if (liveRegion) {
return;
}
if (!message) {
return;
}
setLiveRegion(
buildLiveRegionElement(
document, { attr: DATA_LIVE_ATTRIBUTE, type, role }
)
);
}, [liveRegion, type, role, message]);
// there needs to be a delay between the live region being appended
// to the DOM and the content being appended to the live region
React.useEffect(() => {
if (!message) {
return;
}
if (!liveRegion) {
return;
}
// queue a callback to set the message
window.setTimeout(() => {
liveRegion.textContent = message;
}, delay);
}, [liveRegion, message, delay]);
// Remove the existing live region so we can recreate a new one
// each time. This allows the same announcement to be made
// multiple times.
const removeOldLiveRegion = React.useCallback(() => {
liveRegion?.remove?.();
setLiveRegion(null);
}, [liveRegion]);
const announce = React.useCallback(
(message: string) => {
removeOldLiveRegion();
setMessage(message);
},
[removeOldLiveRegion]
);
return React.useMemo(
() => ({
announce
}),
[announce]
);
}

By establishing these patterns, we gained a dependable method for implementing live regions. This not only standardized how announcements are managed but also improved the overall accessibility of our applications. Ensuring that important updates are clearly conveyed—whether through visual cues, audio, or both—allows us to deliver more inclusive experiences for users engaging with dynamic web content.