diff --git a/README.md b/README.md
index 4937a35..2612ac3 100644
--- a/README.md
+++ b/README.md
@@ -141,6 +141,7 @@ toast.success('Saved!', {
style: { backgroundColor: '#fff' },
dismissible: true,
showCloseButton: true,
+ deduplication: false, // Opt out of deduplication for this toast
});
```
@@ -202,10 +203,41 @@ Available options include:
- **dismissible**: Allow swipe to dismiss
- **showCloseButton**: Show X button
- **defaultDuration**: Default display time in ms
+- **deduplication**: Prevent duplicate toasts (default: `true`, see below)
- **colors**: Custom colors per toast type
- **icons**: Custom icons per toast type
- **toastStyle**, **titleStyle**, **descriptionStyle**: Global style overrides
+### Deduplication
+
+Deduplication is **enabled by default**. When the same toast is shown repeatedly (e.g., rapid button taps), it prevents stacking identical toasts. Instead, it resets the timer and plays a feedback animation:
+
+- **Non-error toasts**: subtle pulse (scale bump)
+- **Error toasts**: shake effect
+
+Disable globally:
+
+```tsx
+
+```
+
+Or per-toast (overrides global config):
+
+```tsx
+// Opt out for a specific toast
+toast.info('New message', { deduplication: false });
+
+// Explicitly enable for a specific toast (redundant when global is on)
+toast.success('Liked!', { deduplication: true });
+```
+
+By default, a toast is considered a duplicate when it matches the **front toast** by title, type, and description. For stable matching across different content, provide an `id` — the existing toast's content will be updated:
+
+```tsx
+toast.success('Saved item 1', { deduplication: true, id: 'save-action' });
+toast.success('Saved item 2', { deduplication: true, id: 'save-action' }); // updates content, resets timer
+```
+
## API Reference
| Method | Description |
diff --git a/example/app/(custom)/index.tsx b/example/app/(custom)/index.tsx
index 9ff114d..9541f87 100644
--- a/example/app/(custom)/index.tsx
+++ b/example/app/(custom)/index.tsx
@@ -112,6 +112,30 @@ export default function CustomScreen() {
No Close Button
+
+ toast.success("Liked!", {
+ description: "Tap again — it won't stack",
+ deduplication: true,
+ })
+ }
+ >
+ Deduplication (Pulse)
+
+
+
+ toast.error("Rate limited", {
+ description: "Please wait before retrying",
+ deduplication: true,
+ })
+ }
+ >
+ Deduplication (Shake)
+
+
diff --git a/example/app/(global)/index.tsx b/example/app/(global)/index.tsx
index d8b5b0c..1b98d2c 100644
--- a/example/app/(global)/index.tsx
+++ b/example/app/(global)/index.tsx
@@ -15,6 +15,7 @@ export default function GlobalConfigScreen() {
const [showCloseButton, setShowCloseButton] = useState(true);
const [customStyle, setCustomStyle] = useState(true);
const [rtl, setRtl] = useState(false);
+ const [deduplication, setDeduplication] = useState(true);
const showToast = () => {
toast.success("Hello!", "This toast uses the global config");
@@ -38,6 +39,7 @@ export default function GlobalConfigScreen() {
rtl,
offset: 8,
defaultDuration: 4000,
+ deduplication,
...(customStyle && {
toastStyle: {
borderRadius: 30,
@@ -146,6 +148,14 @@ export default function GlobalConfigScreen() {
+
+
+ Deduplication
+ Pulse/shake on repeated toasts
+
+
+
+
Custom Styling
diff --git a/package/src/constants.ts b/package/src/constants.ts
index ff02975..235a271 100644
--- a/package/src/constants.ts
+++ b/package/src/constants.ts
@@ -20,4 +20,7 @@ export const DISMISS_VELOCITY_THRESHOLD = 300;
export const STACK_OFFSET_PER_ITEM = 10;
export const STACK_SCALE_PER_ITEM = 0.05;
+export const DEDUPLICATION_PULSE_DURATION = 300;
+export const DEDUPLICATION_SHAKE_DURATION = 400;
+
export const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0);
diff --git a/package/src/pool.ts b/package/src/pool.ts
index 23f5163..b1c8389 100644
--- a/package/src/pool.ts
+++ b/package/src/pool.ts
@@ -5,24 +5,14 @@ export interface AnimSlot {
progress: SharedValue;
translationY: SharedValue;
stackIndex: SharedValue;
-}
-
-export interface SlotTracker {
- wasExiting: boolean;
- prevIndex: number;
- initialized: boolean;
+ deduplication: SharedValue;
}
export const animationPool: AnimSlot[] = Array.from({ length: POOL_SIZE }, () => ({
progress: makeMutable(0),
translationY: makeMutable(0),
stackIndex: makeMutable(0),
-}));
-
-export const slotTrackers: SlotTracker[] = Array.from({ length: POOL_SIZE }, () => ({
- wasExiting: false,
- prevIndex: 0,
- initialized: false,
+ deduplication: makeMutable(0),
}));
const slotAssignments = new Map();
@@ -36,9 +26,6 @@ export const getSlotIndex = (toastId: string): number => {
if (!usedSlots.has(i)) {
slotAssignments.set(toastId, i);
usedSlots.add(i);
- slotTrackers[i].initialized = false;
- slotTrackers[i].wasExiting = false;
- slotTrackers[i].prevIndex = 0;
return i;
}
}
@@ -50,8 +37,5 @@ export const releaseSlot = (toastId: string) => {
if (idx !== undefined) {
usedSlots.delete(idx);
slotAssignments.delete(toastId);
- slotTrackers[idx].initialized = false;
- slotTrackers[idx].wasExiting = false;
- slotTrackers[idx].prevIndex = 0;
}
};
diff --git a/package/src/toast-icons.tsx b/package/src/toast-icons.tsx
index 2c6bcea..da57813 100644
--- a/package/src/toast-icons.tsx
+++ b/package/src/toast-icons.tsx
@@ -46,8 +46,8 @@ export const AnimatedIcon = memo(
}, [progress]);
const style = useAnimatedStyle(() => ({
- opacity: progress.value,
- transform: [{ scale: 0.7 + progress.value * 0.3 }],
+ opacity: progress.get(),
+ transform: [{ scale: 0.7 + progress.get() * 0.3 }],
}));
return {resolveIcon(type, color, custom, config)};
diff --git a/package/src/toast-provider.tsx b/package/src/toast-provider.tsx
index a0d168c..6ff9ffd 100644
--- a/package/src/toast-provider.tsx
+++ b/package/src/toast-provider.tsx
@@ -20,9 +20,15 @@ interface BreadLoafProps {
*
* @property position - Where toasts appear: `'top'` (default) or `'bottom'`
* @property offset - Extra spacing from screen edge in pixels (default: `0`)
+ * @property rtl - Enable right-to-left layout (default: `false`)
* @property stacking - Show multiple toasts stacked (default: `true`). When `false`, only one toast shows at a time
+ * @property maxStack - Maximum visible toasts when stacking (default: `3`)
+ * @property dismissible - Whether toasts can be swiped to dismiss (default: `true`)
+ * @property showCloseButton - Show close button on toasts (default: `true`)
* @property defaultDuration - Default display time in ms (default: `4000`)
+ * @property deduplication - Deduplicate repeated toasts, resetting timer with pulse/shake animation (default: `true`)
* @property colors - Customize colors per toast type (`success`, `error`, `info`, `loading`)
+ * @property icons - Custom icons per toast type
* @property toastStyle - Style overrides for the toast container (borderRadius, shadow, padding, etc.)
* @property titleStyle - Style overrides for the title text
* @property descriptionStyle - Style overrides for the description text
diff --git a/package/src/toast-store.ts b/package/src/toast-store.ts
index 1d21603..ff89967 100644
--- a/package/src/toast-store.ts
+++ b/package/src/toast-store.ts
@@ -23,6 +23,7 @@ const DEFAULT_THEME: ToastTheme = {
titleStyle: {},
descriptionStyle: {},
defaultDuration: 4000,
+ deduplication: true,
};
function mergeConfig(config: ToastConfig | undefined): ToastTheme {
@@ -55,6 +56,7 @@ function mergeConfig(config: ToastConfig | undefined): ToastTheme {
titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle },
descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle },
defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration,
+ deduplication: config.deduplication ?? DEFAULT_THEME.deduplication,
};
}
@@ -104,12 +106,38 @@ class ToastStore {
): string => {
const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration;
const maxToasts = this.theme.stacking ? this.theme.maxStack : 1;
+ const resolvedDescription = description ?? options?.description;
+
+ const shouldDedup = type !== "loading" && (options?.deduplication ?? this.theme.deduplication);
+ if (shouldDedup) {
+ const deduplicationId = options?.id;
+ const frontToast = this.state.visibleToasts.find(t => !t.isExiting);
+ const duplicate = deduplicationId
+ ? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId)
+ : frontToast &&
+ frontToast.title === title &&
+ frontToast.type === type &&
+ frontToast.description === resolvedDescription
+ ? frontToast
+ : undefined;
+
+ if (duplicate) {
+ this.updateToast(duplicate.id, {
+ title,
+ description: resolvedDescription,
+ type,
+ deduplicatedAt: Date.now(),
+ duration: actualDuration,
+ });
+ return duplicate.id;
+ }
+ }
const id = `toast-${++this.toastIdCounter}`;
const newToast: Toast = {
id,
title,
- description: description ?? options?.description,
+ description: resolvedDescription,
type,
duration: actualDuration,
createdAt: Date.now(),
diff --git a/package/src/toast.tsx b/package/src/toast.tsx
index 298cb10..003ef59 100644
--- a/package/src/toast.tsx
+++ b/package/src/toast.tsx
@@ -1,10 +1,18 @@
-import { memo, useCallback, useEffect, useState } from "react";
+import { memo, useCallback, useEffect, useRef, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { Gesture, GestureDetector } from "react-native-gesture-handler";
-import Animated, { interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
+import Animated, {
+ interpolate,
+ useAnimatedStyle,
+ useSharedValue,
+ withSequence,
+ withTiming,
+} from "react-native-reanimated";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { scheduleOnRN } from "react-native-worklets";
import {
+ DEDUPLICATION_PULSE_DURATION,
+ DEDUPLICATION_SHAKE_DURATION,
DISMISS_THRESHOLD,
DISMISS_VELOCITY_THRESHOLD,
EASING,
@@ -22,7 +30,7 @@ import {
SWIPE_EXIT_OFFSET,
} from "./constants";
import { CloseIcon } from "./icons";
-import { type AnimSlot, animationPool, getSlotIndex, releaseSlot, slotTrackers } from "./pool";
+import { type AnimSlot, animationPool, getSlotIndex, releaseSlot } from "./pool";
import { AnimatedIcon, resolveIcon } from "./toast-icons";
import { toastStore } from "./toast-store";
import type { CustomContentRenderFn, ToastItemProps, TopToastRef } from "./types";
@@ -42,12 +50,12 @@ export const ToastContainer = () => {
})
.onUpdate(event => {
"worklet";
- if (!isDismissibleMutable.value) return;
- const ref = topToastMutable.value;
+ if (!isDismissibleMutable.get()) return;
+ const ref = topToastMutable.get();
if (!ref) return;
const { slot } = ref;
- const bottom = isBottomMutable.value;
+ const bottom = isBottomMutable.get();
const rawY = event.translationY;
const dismissDrag = bottom ? rawY : -rawY;
const resistDrag = bottom ? -rawY : rawY;
@@ -70,17 +78,17 @@ export const ToastContainer = () => {
})
.onEnd(() => {
"worklet";
- if (!isDismissibleMutable.value) return;
- const ref = topToastMutable.value;
+ if (!isDismissibleMutable.get()) return;
+ const ref = topToastMutable.get();
if (!ref) return;
const { slot } = ref;
- const bottom = isBottomMutable.value;
- if (shouldDismiss.value) {
+ const bottom = isBottomMutable.get();
+ if (shouldDismiss.get()) {
slot.progress.set(withTiming(0, { duration: EXIT_DURATION, easing: EASING }));
const exitOffset = bottom ? SWIPE_EXIT_OFFSET : -SWIPE_EXIT_OFFSET;
slot.translationY.set(
- withTiming(slot.translationY.value + exitOffset, { duration: EXIT_DURATION, easing: EASING })
+ withTiming(slot.translationY.get() + exitOffset, { duration: EXIT_DURATION, easing: EASING })
);
scheduleOnRN(ref.dismiss);
} else {
@@ -122,7 +130,7 @@ export const ToastContainer = () => {
const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast }: ToastItemProps) => {
const [slotIdx] = useState(() => getSlotIndex(toast.id));
const slot = animationPool[slotIdx];
- const tracker = slotTrackers[slotIdx];
+ const tracker = useRef({ wasExiting: false, prevIndex: 0, initialized: false });
const isBottom = position === "bottom";
const entryFromY = isBottom ? ENTRY_OFFSET : -ENTRY_OFFSET;
@@ -130,12 +138,14 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
const [wasLoading, setWasLoading] = useState(toast.type === "loading");
const [showIcon, setShowIcon] = useState(false);
+ const lastDeduplicatedAt = useRef(toast.deduplicatedAt);
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect
useEffect(() => {
slot.progress.set(0);
slot.translationY.set(0);
slot.stackIndex.set(index);
+ slot.deduplication.set(0);
slot.progress.set(withTiming(1, { duration: ENTRY_DURATION, easing: EASING }));
const iconTimeout = setTimeout(() => setShowIcon(true), 50);
@@ -153,17 +163,17 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
useEffect(() => {
let loadingTimeout: ReturnType | null = null;
- if (toast.isExiting && !tracker.wasExiting) {
- tracker.wasExiting = true;
+ if (toast.isExiting && !tracker.current.wasExiting) {
+ tracker.current.wasExiting = true;
slot.progress.set(withTiming(0, { duration: EXIT_DURATION, easing: EASING }));
slot.translationY.set(withTiming(exitToY, { duration: EXIT_DURATION, easing: EASING }));
}
- if (tracker.initialized && index !== tracker.prevIndex) {
+ if (tracker.current.initialized && index !== tracker.current.prevIndex) {
slot.stackIndex.set(withTiming(index, { duration: STACK_TRANSITION_DURATION, easing: EASING }));
}
- tracker.prevIndex = index;
- tracker.initialized = true;
+ tracker.current.prevIndex = index;
+ tracker.current.initialized = true;
if (toast.type === "loading") {
setWasLoading(true);
@@ -175,6 +185,34 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
registerTopToast({ slot: slot as AnimSlot, dismiss: dismissToast });
}
+ if (toast.deduplicatedAt && toast.deduplicatedAt !== lastDeduplicatedAt.current) {
+ lastDeduplicatedAt.current = toast.deduplicatedAt;
+
+ if (toast.type === "error") {
+ const step = DEDUPLICATION_SHAKE_DURATION / 8;
+ slot.deduplication.set(0);
+ slot.deduplication.set(
+ withSequence(
+ withTiming(6, { duration: step }),
+ withTiming(-5, { duration: step }),
+ withTiming(4, { duration: step }),
+ withTiming(-3, { duration: step }),
+ withTiming(2, { duration: step }),
+ withTiming(-1, { duration: step }),
+ withTiming(0, { duration: step * 2 })
+ )
+ );
+ } else {
+ slot.deduplication.set(0);
+ slot.deduplication.set(
+ withSequence(
+ withTiming(1, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING }),
+ withTiming(0, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING })
+ )
+ );
+ }
+ }
+
return () => {
if (loadingTimeout) clearTimeout(loadingTimeout);
if (isTopToast) registerTopToast(null);
@@ -183,38 +221,42 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
toast.isExiting,
index,
slot,
- tracker,
exitToY,
toast.type,
wasLoading,
isTopToast,
registerTopToast,
dismissToast,
+ toast.deduplicatedAt,
]);
const shouldAnimateIcon = wasLoading && toast.type !== "loading";
+ const isErrorType = toast.type === "error";
const animatedStyle = useAnimatedStyle(() => {
- const baseTranslateY = interpolate(slot.progress.value, [0, 1], [entryFromY, 0]);
+ const baseTranslateY = interpolate(slot.progress.get(), [0, 1], [entryFromY, 0]);
const stackOffsetY = isBottom
- ? slot.stackIndex.value * STACK_OFFSET_PER_ITEM
- : slot.stackIndex.value * -STACK_OFFSET_PER_ITEM;
- const stackScale = 1 - slot.stackIndex.value * STACK_SCALE_PER_ITEM;
+ ? slot.stackIndex.get() * STACK_OFFSET_PER_ITEM
+ : slot.stackIndex.get() * -STACK_OFFSET_PER_ITEM;
+ const stackScale = 1 - slot.stackIndex.get() * STACK_SCALE_PER_ITEM;
- const finalTranslateY = baseTranslateY + slot.translationY.value + stackOffsetY;
+ const finalTranslateY = baseTranslateY + slot.translationY.get() + stackOffsetY;
- const progressOpacity = interpolate(slot.progress.value, [0, 1], [0, 1]);
- const dismissDirection = isBottom ? slot.translationY.value : -slot.translationY.value;
+ const progressOpacity = interpolate(slot.progress.get(), [0, 1], [0, 1]);
+ const dismissDirection = isBottom ? slot.translationY.get() : -slot.translationY.get();
const dragOpacity = dismissDirection > 0 ? interpolate(dismissDirection, [0, 130], [1, 0], "clamp") : 1;
const opacity = progressOpacity * dragOpacity;
- const dragScale = interpolate(Math.abs(slot.translationY.value), [0, 50], [1, 0.98], "clamp");
- const scale = stackScale * dragScale;
+ const dedupVal = slot.deduplication.get();
+ const dragScale = interpolate(Math.abs(slot.translationY.get()), [0, 50], [1, 0.98], "clamp");
+ const pulseScale = isErrorType ? 1 : 1 + dedupVal * 0.03;
+ const scale = stackScale * dragScale * pulseScale;
+ const shakeTranslateX = isErrorType ? dedupVal : 0;
return {
- transform: [{ translateY: finalTranslateY }, { scale }],
+ transform: [{ translateY: finalTranslateY }, { translateX: shakeTranslateX }, { scale }],
opacity,
- zIndex: 1000 - Math.round(slot.stackIndex.value),
+ zIndex: 1000 - Math.round(slot.stackIndex.get()),
};
});
@@ -320,6 +362,7 @@ const MemoizedToastItem = memo(ToastItem, (prev, next) => {
prev.toast.title === next.toast.title &&
prev.toast.description === next.toast.description &&
prev.toast.isExiting === next.toast.isExiting &&
+ prev.toast.deduplicatedAt === next.toast.deduplicatedAt &&
prev.index === next.index &&
prev.position === next.position &&
prev.theme === next.theme &&
diff --git a/package/src/types.ts b/package/src/types.ts
index 4e9b907..eccb6c1 100644
--- a/package/src/types.ts
+++ b/package/src/types.ts
@@ -70,10 +70,14 @@ export interface ToastTheme {
descriptionStyle: TextStyle;
/** Default duration in ms for toasts (default: 4000) */
defaultDuration: number;
+ /** When true, duplicate toasts reset the timer and play a feedback animation. Matches by title+type+description, or by `id` if provided. (default: true) */
+ deduplication: boolean;
}
/** Per-toast options for customizing individual toasts */
export interface ToastOptions {
+ /** Stable key for deduplication. When set, toasts with the same `id` deduplicate and update the existing toast's content. Without an `id`, matching falls back to title+type+description against the front toast. */
+ id?: string;
/** Description text */
description?: string;
/** Duration in ms (overrides default) */
@@ -96,6 +100,8 @@ export interface ToastOptions {
* Receives props: { id, dismiss, type, isExiting }
*/
customContent?: ReactNode | CustomContentRenderFn;
+ /** Enable deduplication for this toast (overrides global config). Plays a pulse animation for non-error toasts or a shake for errors. Use with `id` for stable matching across different content. */
+ deduplication?: boolean;
}
/** Configuration options for customizing toast behavior and appearance. All properties are optional. */
@@ -115,6 +121,7 @@ export interface Toast {
duration: number;
createdAt: number;
isExiting?: boolean;
+ deduplicatedAt?: number;
/** Per-toast style/icon overrides */
options?: ToastOptions;
}