Skip to main content

Pause-on-Hover: Making Toast Notifications Respect the User

A notification bell icon on a blurred background
Apr 6, 20262 min readReact, UX, Design Systems, Jest

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" with aria-live="polite": for success and info toasts. The screen reader waits for a natural pause before announcing.
  • role="alert" with implicit aria-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.