The timing conflict
A toast notification has two jobs that pull against each other. For sighted
users it should disappear after a few seconds so it doesn't block the UI, but
for screen reader users, aria-live="polite" means the announcement waits
until the user finishes their current task, and if the toast has already been
yanked out of the DOM by then, there's nothing left to announce.
The Toast component had neither: no ARIA roles, no auto-dismiss strategy, and
a close button with no accessible label. It was a styled <div> that appeared
and vanished.
role="alert" vs role="status"
Not every toast deserves the same urgency, and the ARIA spec gives me two options:
role="status"witharia-live="polite": for success and info toasts. The screen reader waits for a natural pause before announcing.role="alert"with implicitaria-live="assertive": for error and warning toasts. The screen reader interrupts whatever it's doing.
The distinction matters: an "email sent" confirmation can wait, but a "payment failed" error shouldn't.
const isUrgent = t.variant === 'error' || t.variant === 'warning';
<div
role={isUrgent ? 'alert' : 'status'}
aria-atomic='true'
>aria-atomic="true" makes the screen reader read the entire toast
content when it changes, not just the diff.
The pause-on-hover pattern
Auto-dismiss solves the sighted user's problem, since toasts go away on their own, but a 4-second timer is hostile if the user is still reading. So the fix is to pause the timer while the user is engaged.
I pulled out a ToastCard component that manages its own lifecycle with
three refs:
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const remainingRef = useRef(AUTO_DISMISS_MS);
const startRef = useRef(Date.now());
const startTimer = useCallback(() => {
startRef.current = Date.now();
timerRef.current = setTimeout(() => {
onDismiss(t.id);
}, remainingRef.current);
}, [onDismiss, t.id]);
const pauseTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
remainingRef.current -= Date.now() - startRef.current;
}
}, []);The trick is that remainingRef tracks how much time is left, not how much
has elapsed, so when the user hovers away the timer resumes with the correct
remaining duration instead of a fresh 4 seconds.
Both mouse and keyboard users get this for free:
<div
onMouseEnter={pauseTimer}
onMouseLeave={startTimer}
onFocus={pauseTimer}
onBlur={startTimer}
>The onFocus/onBlur pair covers keyboard users who Tab into the toast
to reach the close button.
Labeling the close button
A bare <button> with an × icon is a dead end for a screen reader; it
just announces "button" with no hint of what it does or what it dismisses.
<button aria-label='Dismiss notification' onClick={() => onDismiss(t.id)}>
<X className='h-4 w-4' />
</button>Small detail, big payoff. A screen reader user now hears "Dismiss notification, button" and knows exactly what the control does.
Testing async behavior
Auto-dismiss and pause-on-hover are both time-dependent, which makes the tests
inherently flaky if you do them wrong. I leaned on jest.useFakeTimers() and
act() to control the clock:
- Verify the toast exists at
t=0 - Advance timers past
AUTO_DISMISS_MS - Verify the toast is removed
For pause-on-hover, I fire mouseEnter mid-timer, advance past the
original deadline, check that the toast survives, then fire mouseLeave and
advance through the remaining time.
Nine new tests cover the full matrix: role by variant, aria attributes, close-button labeling, and the pause/resume lifecycle.
The takeaway
Toast notifications sit right where visual design, timing, and assistive
technology overlap. The three-ref timer pattern (timerRef, remainingRef,
startRef) is a clean, reusable approach for any UI element that needs
interruptible auto-dismiss, and the role="alert" vs role="status"
choice is one of those small ARIA calls that separates a component library
from an accessible one.
