honza.codes
← Back

Mindful toast notifications

Duplicate toasts are everywhere, and they bother me more than they probably should. Double-click a copy button — two "Copied!" messages. An API errors out in a loop — the screen fills with identical alerts. And for screen reader users, every one of those fires a separate announcement. Not great.

I've always liked how Linear handles their toasts. So I wanted to see how hard this would be to implement on top of Base UI, a popular headless library. More specifically, I picked Coss UI because I like what they added on top of Base UI's toast, both the API and the styling.


The classic: copy to clipboard

The most common dedup scenario. Rage-click "Copy link" five times in a row. The left side creates five identical toasts. The right side keeps one and plays a subtle pulsate on each repeat.

Before

Rage-click to see the pile-up

After

Rage-click too — deduped


Anchored variant

This is one of my favorite interaction patterns built into Base UI. Not every confirmation needs to go to the global toast stack — sometimes it's better to show feedback right where the action happened, anchored to the trigger element. Feels more natural than a toast flying in from the corner of the screen.

Same dedup behavior applies: repeated clicks pulsate instead of stacking. Pay attention to the "Before" side: notice how each new toast stacks on top of the previous one, and with every new layer the shadow behind the toast intensifies, creating an increasingly dark blob. The "After" side avoids all of that. No disabling the button, no throttling clicks. The dedup layer handles it.

Before

Each click adds another layer

After

Same clicks — deduped in place


Guiding principles

Extending a headless component like Base UI's toast means you can break things in subtle ways. Three rules I set for myself:

  • Don't mess with the stacking logic. We do tap into a Base UI internal API (more on that later), but the stack height tracking, CSS variable-driven positioning transforms, and enter/exit animation lifecycle stay untouched.
  • Don't break accessibility. One toast, one screen reader announcement. No extra noise.
  • Make dedup invisible to consumers. Dedup should just happen at the infrastructure level. The API stays identical to Base UI's toast manager, and consumers never have to think about it.

So, how does it work?

The dedup layer wraps Base UI's createToastManager and intercepts every add() call. The key to making dedup invisible is automatic fingerprinting. Every toast gets a dedup key derived from its type + title + description — same content, same key. No one has to remember to define a key, and we don't make the key required. It just happens. With one exception: custom content with JSX, where you can't meaningfully compare React nodes. For those, you provide an explicit dedupeKey.

When a new toast comes in, the manager checks if a toast with the same fingerprint is already visible. If not, it's passed through to Base UI normally. But if there's a match, the behavior depends on where the duplicate sits in the stack:

  • If the duplicate is the frontmost toast in the stack (the one the user can actually see), we keep it in place, reset its timer, and play a repeat effect (pulsate or shake).
  • If it's buried behind other toasts, the effect would be invisible. Instead of leaving the duplicate hidden in the stack, we close the old one and add a fresh toast that enters at the top naturally. This also means we don't spam the stack with repeated identical toasts — the old one is cleaned up.

Here's the frontmost path. The timer reset and repeat signal are piggybacked onto a single update() call:

dedupe-toast-manager.ts — frontmost duplicate handling
const effect = repeatEffect ?? getDefaultEffect(rest.type)
const previousData = toastDataById.get(existingId) ?? {}
const previousRepeatCount =
  typeof previousData.repeatCount === 'number'
    ? previousData.repeatCount
    : 0
const mergedData = {
  ...previousData,          // ← preserve existing consumer metadata
  ...(rest.data ?? {}),     // ← merge any new data from the caller
  repeatCount: previousRepeatCount + 1,
  repeatEffect: effect,
}

baseManager.update(existingId, {
  timeout: rest.timeout,  // ← must be explicit (see below)
  data: mergedData,
})
toastDataById.set(existingId, mergedData)
return existingId

Two gotchas here. First: Base UI's update() only resets the auto-dismiss timer if you pass a timeout value explicitly. Omit it, and the toast keeps its original countdown — a deduplicated toast can disappear almost immediately if the original was about to expire.

Second: the data merge. The toast might already carry consumer metadata — anchored tooltip flags, custom styling, anything passed through data. If you just overwrite it with the new repeat count, you lose all of that. The spread of previousData and rest.data preserves everything while adding the repeat signal on top.


Animating the repeat effect

When a duplicate hits the frontmost toast, we play a visual effect. Errors shake. Everything else pulsates.

My first instinct was motion.dev for the animations. But its animate() sets inline transform on the element, which overrides the CSS transform rules that Base UI uses for stack positioning. Toasts jumped to wrong positions.

The solution: CSS @keyframes using individual transform properties (scale, translate). CSS keyframes don't leave inline styles on the element, so they don't fight with Base UI's positioning. The React component toggles a data-repeat-effect attribute, matched by CSS selectors:

index.css — repeat effect keyframes
@keyframes toast-pulsate {
  0%   { scale: 1; }
  50%  { scale: 1.05; }
  100% { scale: 1; }
}

@keyframes toast-shake {
  20% { translate: -4px 0; }
  40% { translate: 4px 0; }
  60% { translate: -4px 0; }
  80% { translate: 4px 0; }
}

[data-repeat-effect='pulsate'] { animation: toast-pulsate 0.5s ease-in; }
[data-repeat-effect='shake']   { animation: toast-shake 0.4s ease-out; }

One subtlety: CSS animations don't restart when you re-set the same attribute value. The fix is to remove the data-repeat-effect attribute, wait one requestAnimationFrame, then re-add it. The browser treats this as a new animation. Clean up the pending rAF on unmount to avoid firing on a detached element.

Keep update() data lightweight. Base UI's toast stack relies on precise height tracking, and calling update() triggers a re-render that can desync the stack's offset calculations if it causes layout changes. We only pass the repeat count and timer through update(), and drive the actual animation via DOM attributes, not React state.

Try the different types below — errors shake, successes pulsate:

Without dedup
With dedup

Tracking toast order

To know whether a duplicate is frontmost or buried, we need to track insertion order. Base UI does expose toast order through the useToastManager hook, but that's a React hook and our dedup logic lives in the manager factory, outside of React's render cycle.

Instead, we maintain a parallel toastOrder array, synced via Base UI's internal ' subscribe' API. Note the leading space — it's a private, undocumented API. This is the only way I found to observe toast lifecycle events from outside of a React component.

dedupe-toast-manager.ts — lifecycle subscription
baseManager[' subscribe']((event) => {
  const id = event.options?.id
  if (!id) return
  if (event.action === 'add') registerToastOrder(id)
  if (event.action === 'close') unregisterToastOrder(id)
})

What about accessibility?

I went back and forth on whether to announce the repeat animation as a separate toast, or maybe append something like "displayed 3 times" to the announcement. WCAG actually warns about this:

"Live regions and alerts can be usefully applied in many situations where a change of content takes place which does not constitute a status message, as defined in this success criterion. However, there is a risk of making an application too 'chatty' for a screen reader user."— WCAG 2.1, Understanding 4.1.3

So I went with a single announcement. The pulsate effect tells sighted users the action registered. The screen reader stays quiet after the first announcement.


Updated toast API

The API stays identical to Base UI's toast manager — add, close, update, promise — with a couple of extra options for dedup control.

Sure, you could build this as a separate dedupedToastManager that consumers explicitly opt into. But if I was shipping this in a design system, I wouldn't want to leave deduplication up to the consumer. It should just happen at the component level, so every team gets consistent behavior without thinking about it.

The dedupe: false escape hatch exists for the rare case where stacking is actually desired (e.g. distinct download completion notifications). Though I'd argue those should have different titles or descriptions anyway, so they'd naturally get different fingerprints and not trigger dedup at all.

Usage examples
import { toastManager } from './components/ui/toast'

// Basic — dedup is on by default, consumers don't even know
toastManager.add({
  title: 'Copied to clipboard',
  type: 'success',
})

// Explicit dedup key (for JSX titles or custom grouping)
toastManager.add({
  title: <span>Custom <strong>JSX</strong> title</span>,
  dedupeKey: 'custom-save',
  type: 'success',
})

// Opt out of dedup for a specific toast
toastManager.add({
  title: 'Downloaded report.pdf',
  dedupe: false,
})

// Override the repeat effect
toastManager.add({
  title: 'Rate limited',
  type: 'warning',
  repeatEffect: 'shake',  // shake instead of default pulsate
})

Get the code

The toast system is two files plus a few CSS keyframes. dedupe-toast-manager.ts is the dedup layer — pure logic, no React, wrapping Base UI's toast manager. It lives in its own file because it's essentially middleware on top of the toast. toast.tsx has the React components: providers, viewports, and the toast card with repeat-effect animations. The CSS keyframes go in your global stylesheet.

npx shadcn@latest add https://honza.codes/registry/toast.json

This copies the source files into your project via the shadcn registry.

Requires @base-ui/react, lucide-react, tailwind-merge, and clsx as peer dependencies.