Handle edge cases invisibly

Pause unseen timers, keep hover steady, never cover the UI in use.

Emil Kowalskiemilkowal.ski
Building a toast componentDemo 1 ↓Read article ↗
Emil Kowalski@emilkowalski
That's why there's a `useIsDocumentHidden` hook that checks if the document (tab) is hidden, and if so, it will pause the timer. This can be done with the "visibilitychange" event listener on the document. A small detail that improves the experience.
Aug 31, 2026Demo 2 ↓View on X ↗
Emil Kowalski@emilkowalski
Introducing Sonner - an opinionated toast component for React.
Feb 23, 2023View on X ↗
Emil Kowalski@emilkowalski
When you were using the Linear Agent and a toast was render, it covered the chat. Now the toast renders next to it and changes position when the chat is open/closed.
Sep 9, 2026Demo 4 ↓View on X ↗

From emil-design-eng, Sonner principles.

Fill the gaps between toasts

Hover a stack, then move slowly up across the gaps.

Gaps are dead spaceBad
    no :aftercollapsed 0×
    :after fills each gapGood
      ::after { height: var(--gap) }collapsed 0×
      .toast::after {
        content: "";
        position: absolute;
        left: 0;
        right: 0;
        top: 100%;
        height: var(--gap);
      }

      Pause when nobody can read

      Send, then hover a toast or tick Tab hidden.

      Timer always runsBad
        idle · 0 missed
        Pauses when unseenGood
          idle · 0 missed
          let remaining = 4000;
          let last = performance.now();
          const paused = () => hovered || focused || document.hidden;
          
          document.addEventListener("visibilitychange", () => {
            last = performance.now();
          });
          
          function tick(now) {
            if (!paused()) remaining -= now - last;
            last = now;
            if (remaining <= 0) dismiss();
            else requestAnimationFrame(tick);
          }

          Don't cover the UI in use

          Send a few chat messages, then toggle Agent.

          Fixed bottom-rightBad
          Issues
            right: 8px
            Moves beside the chatGood
            Issues
              translateX(-chat-width)
              .app { container-type: inline-size; --chat-width: 56cqi; }
              .toaster { transition: transform 300ms var(--ease-in-out); }
              .app[data-chat="open"] .toaster {
                transform: translateX(calc(-1 * var(--chat-width)));
              }

              Why it matters, pros and cons

              Why it matters

              A toast that expires unseen or hides your input has failed silently.

              Pros

              • The 4 seconds count only when visible.
              • The stack stays open under the pointer.
              • Nothing to configure, nothing to get wrong.

              Cons

              • Paused toasts pile up; cap visible toasts at 3.
              • The gap fill can swallow clicks below the stack.
              • Critical errors should not be toasts at all.