The Timing Conflict
A toast notification has two jobs that fight each other. For sighted users,
it should disappear after a few seconds so it doesn't block the UI. For
screen reader users, aria-live="polite" means the announcement waits
until the user finishes their current task, but if the toast has already
been removed from 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 all toasts deserve the same urgency. 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. A "payment failed" error should not.
const isUrgent = t.variant === 'error' || t.variant === 'warning';
<div
role={isUrgent ? 'alert' : 'status'}
aria-atomic='true'
>aria-atomic="true" ensures the screen reader reads the entire toast
content when it changes, not just the diff.
The Pause-on-Hover Pattern
Auto-dismiss solves the sighted user's problem: toasts go away on their own. But a 4-second timer is hostile if the user is still reading. The fix: pause the timer when the user is engaged.
I extracted 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 key insight: remainingRef tracks how much time is left, not how much
has elapsed. When the user hovers away, the timer resumes with the correct
remaining duration — not a fresh 4 seconds.
Both mouse and keyboard users benefit:
<div
onMouseEnter={pauseTimer}
onMouseLeave={startTimer}
onFocus={pauseTimer}
onBlur={startTimer}
>The onFocus/onBlur pair handles keyboard users who Tab into the toast
to reach the close button.
Labeling the Close Button
A bare <button> with an × icon is a screen reader dead end. It
announces "button" with no indication 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, high impact. 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 time-dependent, which makes tests
inherently flaky if done wrong. I used 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, verify 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 at the intersection of visual design, timing, and
assistive technology. The three-ref timer pattern (timerRef, remainingRef,
startRef) is a clean, reusable approach to any UI element that needs
interruptible auto-dismiss. And the role="alert" vs role="status"
distinction is one of those small ARIA decisions that separates a component
library from an accessible one.
