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};