Loading
Back to labs

Toast Deduper

A drop-in wrapper around Sonner that stops identical toasts from stacking. Duplicates update the existing toast in place and play a nudge shake instead of spawning another notification.

Deduped Sonner toasts with nudge

Repeated string messages reuse one toast id. Each duplicate toggles between toast-nudge-a and toast-nudge-b so the shake animation restarts without fighting Sonner’s transform-based stacking.

Notes

  • Deduplicates by type + message content — no call-site changes.
  • Duplicates update in place via Sonner’s id API and play a nudge shake.
  • Uses CSS translate (not transform) so Sonner’s vertical stacking stays intact.

Code

A compact implementation sketch for the pattern behind this lab.

1'use client';2 3import {4  toast as sonnerToast,5  type ExternalToast,6} from 'sonner';7 8const activeToasts = new Map<string, string | number>();9const nudgeCounts = new Map<string, number>();10 11function getToastKey(message: unknown, type: string): string | null {12  if (typeof message === 'string') return `${type}:${message}`;13  return null;14}15 16function cleanupHandlers(17  key: string | null,18  data?: ExternalToast,19): Pick<ExternalToast, 'onDismiss' | 'onAutoClose'> {20  return {21    onDismiss: (t) => {22      if (key) {23        activeToasts.delete(key);24        nudgeCounts.delete(key);25      }26      data?.onDismiss?.(t);27    },28    onAutoClose: (t) => {29      if (key) {30        activeToasts.delete(key);31        nudgeCounts.delete(key);32      }33      data?.onAutoClose?.(t);34    },35  };36}37 38function notify(39  type: 'success' | 'error' | 'info' | 'warning' | 'message',40  message: string | React.ReactNode,41  data?: ExternalToast,42) {43  const key = getToastKey(message, type);44  const play =45    type === 'message' ? sonnerToast : sonnerToast[type];46 47  if (key && activeToasts.has(key)) {48    const id = activeToasts.get(key)!;49    const count = (nudgeCounts.get(key) ?? 0) + 1;50    nudgeCounts.set(key, count);51    const className =52      count % 2 === 0 ? 'toast-nudge-a' : 'toast-nudge-b';53 54    return play(message as string, {55      ...data,56      id,57      className,58      ...cleanupHandlers(key, data),59    });60  }61 62  const id = play(message as string, {63    ...data,64    ...cleanupHandlers(key, data),65  });66 67  if (key) activeToasts.set(key, id);68  return id;69}70 71export const toast = {72  success: (message: string | React.ReactNode, data?: ExternalToast) =>73    notify('success', message, data),74  error: (message: string | React.ReactNode, data?: ExternalToast) =>75    notify('error', message, data),76  info: (message: string | React.ReactNode, data?: ExternalToast) =>77    notify('info', message, data),78  warning: (message: string | React.ReactNode, data?: ExternalToast) =>79    notify('warning', message, data),80  message: (message: string | React.ReactNode, data?: ExternalToast) =>81    notify('message', message, data),82};