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 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" 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, 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.