Skip to content

fix(ui): prevent toast from stealing focus on open#840

Merged
arnestrickmann merged 1 commit intomainfrom
emdash/toastfocusbug-2it
Feb 12, 2026
Merged

fix(ui): prevent toast from stealing focus on open#840
arnestrickmann merged 1 commit intomainfrom
emdash/toastfocusbug-2it

Conversation

@arnestrickmann
Copy link
Contributor

Summary

  • Fix Radix toast viewport stealing focus from the active element (e.g. terminal, editor) when a toast appears
  • Track the last focused element outside the toast viewport and restore focus after toast count changes using requestAnimationFrame
  • Add destructive variant icon (AlertCircle) and improve toast layout with flex styling
  • Add pnpm-workspace.yaml with onlyBuiltDependencies allowlist

Details

Radix ToastProvider moves focus to the toast viewport when a new toast opens, which interrupts the user's workflow (e.g. typing in a terminal or editor). This fix listens for focusin events to remember the last focused element outside the toast, then restores it whenever the toast count changes and focus has been stolen.

@vercel
Copy link

vercel bot commented Feb 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
docs Ready Ready Preview, Comment Feb 12, 2026 10:50pm

Request Review

@greptile-apps
Copy link

greptile-apps bot commented Feb 12, 2026

Greptile Overview

Greptile Summary

This PR fixes an annoying UX issue where Radix toast notifications steal focus from the active element (terminal, editor) when they appear. The fix tracks the last focused element outside the toast viewport using a focusin listener and restores it after toast count changes.

Key changes:

  • Renamed previousActiveElement to lastFocusedOutsideToast for clarity
  • Added isInToastViewport helper to check if element is inside toast
  • Added continuous focusin listener to track last focused element outside toast (previously only captured on toast appearance)
  • Moved focus restoration logic to run on any toast count change using requestAnimationFrame
  • Added AlertCircle icon for destructive variant toasts
  • Improved toast layout with flex styling
  • Added standard packages field to pnpm-workspace.yaml

Potential consideration:
The focus restoration currently runs when toasts are added OR removed. If a user intentionally moves focus away from the terminal/editor while a toast is displayed, the focus will be restored when the toast disappears. Consider whether focus should only be restored when toasts are added (not removed).

Confidence Score: 4/5

  • This PR is safe to merge with one minor behavior consideration
  • The focus management logic is well-implemented and solves a real UX problem. The code is clean, uses appropriate React patterns (useEffect, useRef, requestAnimationFrame), and adds proper event listener cleanup. One minor consideration is whether focus should be restored when toasts disappear (not just when they appear) - this could cause unexpected refocusing if the user intentionally moved focus during toast display. The pnpm-workspace.yaml change is standard and safe.
  • No files require special attention - the implementation is solid, with just one minor behavioral consideration noted in comments

Important Files Changed

Filename Overview
src/renderer/components/ui/toaster.tsx Improved focus restoration logic to prevent toast from stealing focus from active elements (terminal, editor)
pnpm-workspace.yaml Added standard packages field to pnpm workspace config

Last reviewed commit: 5d2b0d6

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 1 comment

Edit Code Review Agent Settings | Greptile

Comment on lines 46 to 68
useEffect(() => {
const currentToastsCount = toasts.length;
const toastsChanged = currentToastsCount !== previousToastsCount.current;
if (!toastsChanged) return;

if (toastsChanged) {
// Save the currently focused element when a toast appears
if (currentToastsCount > previousToastsCount.current) {
previousActiveElement.current = document.activeElement as HTMLElement;
}

// Only restore focus if it was stolen by the toast (i.e., current focus is on toast or body)
// Don't restore if user intentionally moved focus elsewhere
requestAnimationFrame(() => {
const currentFocus = document.activeElement;
const focusIsOnToast = currentFocus?.closest('[data-radix-toast-viewport]') !== null;
const focusIsOnBody = currentFocus === document.body;
requestAnimationFrame(() => {
const currentFocus = document.activeElement;
const focusIsOnToast = isInToastViewport(currentFocus);
const focusIsOnBody = currentFocus === document.body;
const restoreTarget = lastFocusedOutsideToast.current;

// Only restore if focus is on toast/body AND we have a valid saved element
if (
(focusIsOnToast || focusIsOnBody) &&
previousActiveElement.current &&
document.body.contains(previousActiveElement.current)
) {
previousActiveElement.current.focus();
}
});
}
if (
(focusIsOnToast || focusIsOnBody) &&
restoreTarget &&
document.body.contains(restoreTarget) &&
restoreTarget !== document.activeElement
) {
restoreTarget.focus();
}
});

previousToastsCount.current = currentToastsCount;
}, [toasts.length]);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The focus restoration also runs when toasts are removed (count decreases), potentially re-focusing elements when the user has intentionally moved focus elsewhere during toast display.

Consider restoring focus only when count increases:

Suggested change
useEffect(() => {
const currentToastsCount = toasts.length;
const toastsChanged = currentToastsCount !== previousToastsCount.current;
if (!toastsChanged) return;
if (toastsChanged) {
// Save the currently focused element when a toast appears
if (currentToastsCount > previousToastsCount.current) {
previousActiveElement.current = document.activeElement as HTMLElement;
}
// Only restore focus if it was stolen by the toast (i.e., current focus is on toast or body)
// Don't restore if user intentionally moved focus elsewhere
requestAnimationFrame(() => {
const currentFocus = document.activeElement;
const focusIsOnToast = currentFocus?.closest('[data-radix-toast-viewport]') !== null;
const focusIsOnBody = currentFocus === document.body;
requestAnimationFrame(() => {
const currentFocus = document.activeElement;
const focusIsOnToast = isInToastViewport(currentFocus);
const focusIsOnBody = currentFocus === document.body;
const restoreTarget = lastFocusedOutsideToast.current;
// Only restore if focus is on toast/body AND we have a valid saved element
if (
(focusIsOnToast || focusIsOnBody) &&
previousActiveElement.current &&
document.body.contains(previousActiveElement.current)
) {
previousActiveElement.current.focus();
}
});
}
if (
(focusIsOnToast || focusIsOnBody) &&
restoreTarget &&
document.body.contains(restoreTarget) &&
restoreTarget !== document.activeElement
) {
restoreTarget.focus();
}
});
previousToastsCount.current = currentToastsCount;
}, [toasts.length]);
useEffect(() => {
const currentToastsCount = toasts.length;
const toastsChanged = currentToastsCount !== previousToastsCount.current;
const toastsIncreased = currentToastsCount > previousToastsCount.current;
if (!toastsChanged || !toastsIncreased) {
previousToastsCount.current = currentToastsCount;
return;
}
requestAnimationFrame(() => {
const currentFocus = document.activeElement;
const focusIsOnToast = isInToastViewport(currentFocus);
const focusIsOnBody = currentFocus === document.body;
const restoreTarget = lastFocusedOutsideToast.current;
if (
(focusIsOnToast || focusIsOnBody) &&
restoreTarget &&
document.body.contains(restoreTarget) &&
restoreTarget !== document.activeElement
) {
restoreTarget.focus();
}
});
previousToastsCount.current = currentToastsCount;
}, [toasts.length]);

@arnestrickmann arnestrickmann merged commit 52857ee into main Feb 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant