Skip to content

Commit 12c319c

Browse files
committed
feat: deduplication toasts with nice animation
1 parent 2ba08b3 commit 12c319c

10 files changed

Lines changed: 174 additions & 24 deletions

File tree

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ toast.success('Saved!', {
141141
style: { backgroundColor: '#fff' },
142142
dismissible: true,
143143
showCloseButton: true,
144+
deduplication: true, // Prevents duplicate toasts, resets timer instead
144145
});
145146
```
146147

@@ -202,10 +203,41 @@ Available options include:
202203
- **dismissible**: Allow swipe to dismiss
203204
- **showCloseButton**: Show X button
204205
- **defaultDuration**: Default display time in ms
206+
- **deduplication**: Prevent duplicate toasts (see below)
205207
- **colors**: Custom colors per toast type
206208
- **icons**: Custom icons per toast type
207209
- **toastStyle**, **titleStyle**, **descriptionStyle**: Global style overrides
208210

211+
### Deduplication
212+
213+
When the same toast is shown repeatedly (e.g., rapid button taps), deduplication prevents stacking identical toasts. Instead, it resets the timer and plays a feedback animation:
214+
215+
- **Non-error toasts**: subtle pulse (scale bump)
216+
- **Error toasts**: shake effect
217+
218+
Enable globally:
219+
220+
```tsx
221+
<BreadLoaf config={{ deduplication: true }} />
222+
```
223+
224+
Or per-toast (overrides global config):
225+
226+
```tsx
227+
toast.success('Liked!', { deduplication: true });
228+
toast.error('Rate limited', { deduplication: true });
229+
230+
// Opt out for a specific toast even when global is on
231+
toast.info('New message', { deduplication: false });
232+
```
233+
234+
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:
235+
236+
```tsx
237+
toast.success('Saved item 1', { deduplication: true, id: 'save-action' });
238+
toast.success('Saved item 2', { deduplication: true, id: 'save-action' }); // updates content, resets timer
239+
```
240+
209241
## API Reference
210242

211243
| Method | Description |

example/app/(custom)/index.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,30 @@ export default function CustomScreen() {
112112
<Text style={styles.buttonText}>No Close Button</Text>
113113
</TouchableOpacity>
114114

115+
<TouchableOpacity
116+
style={[styles.button, { backgroundColor: "#f59e0b" }]}
117+
onPress={() =>
118+
toast.success("Liked!", {
119+
description: "Tap again — it won't stack",
120+
deduplication: true,
121+
})
122+
}
123+
>
124+
<Text style={styles.buttonText}>Deduplication (Pulse)</Text>
125+
</TouchableOpacity>
126+
127+
<TouchableOpacity
128+
style={[styles.button, { backgroundColor: "#dc2626" }]}
129+
onPress={() =>
130+
toast.error("Rate limited", {
131+
description: "Please wait before retrying",
132+
deduplication: true,
133+
})
134+
}
135+
>
136+
<Text style={styles.buttonText}>Deduplication (Shake)</Text>
137+
</TouchableOpacity>
138+
115139
<TouchableOpacity
116140
style={[styles.button, { backgroundColor: "#8b5cf6" }]}
117141
onPress={() =>

example/app/(global)/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default function GlobalConfigScreen() {
1515
const [showCloseButton, setShowCloseButton] = useState(true);
1616
const [customStyle, setCustomStyle] = useState(true);
1717
const [rtl, setRtl] = useState(false);
18+
const [deduplication, setDeduplication] = useState(true);
1819

1920
const showToast = () => {
2021
toast.success("Hello!", "This toast uses the global config");
@@ -38,6 +39,7 @@ export default function GlobalConfigScreen() {
3839
rtl,
3940
offset: 8,
4041
defaultDuration: 4000,
42+
deduplication,
4143
...(customStyle && {
4244
toastStyle: {
4345
borderRadius: 30,
@@ -146,6 +148,14 @@ export default function GlobalConfigScreen() {
146148
<Switch value={rtl} onValueChange={setRtl} />
147149
</View>
148150

151+
<View style={styles.option}>
152+
<View>
153+
<Text style={styles.optionLabel}>Deduplication</Text>
154+
<Text style={styles.optionDesc}>Pulse/shake on repeated toasts</Text>
155+
</View>
156+
<Switch value={deduplication} onValueChange={setDeduplication} />
157+
</View>
158+
149159
<View style={styles.option}>
150160
<View>
151161
<Text style={styles.optionLabel}>Custom Styling</Text>

package/src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ export const DISMISS_VELOCITY_THRESHOLD = 300;
2020
export const STACK_OFFSET_PER_ITEM = 10;
2121
export const STACK_SCALE_PER_ITEM = 0.05;
2222

23+
export const DEDUPLICATION_PULSE_DURATION = 300;
24+
export const DEDUPLICATION_SHAKE_DURATION = 400;
25+
2326
export const EASING = Easing.bezier(0.25, 0.1, 0.25, 1.0);

package/src/pool.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export interface AnimSlot {
55
progress: SharedValue<number>;
66
translationY: SharedValue<number>;
77
stackIndex: SharedValue<number>;
8+
deduplication: SharedValue<number>;
89
}
910

1011
export interface SlotTracker {
@@ -17,6 +18,7 @@ export const animationPool: AnimSlot[] = Array.from({ length: POOL_SIZE }, () =>
1718
progress: makeMutable(0),
1819
translationY: makeMutable(0),
1920
stackIndex: makeMutable(0),
21+
deduplication: makeMutable(0),
2022
}));
2123

2224
export const slotTrackers: SlotTracker[] = Array.from({ length: POOL_SIZE }, () => ({

package/src/toast-icons.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ export const AnimatedIcon = memo(
4646
}, [progress]);
4747

4848
const style = useAnimatedStyle(() => ({
49-
opacity: progress.value,
50-
transform: [{ scale: 0.7 + progress.value * 0.3 }],
49+
opacity: progress.get(),
50+
transform: [{ scale: 0.7 + progress.get() * 0.3 }],
5151
}));
5252

5353
return <Animated.View style={style}>{resolveIcon(type, color, custom, config)}</Animated.View>;

package/src/toast-provider.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,15 @@ interface BreadLoafProps {
2020
*
2121
* @property position - Where toasts appear: `'top'` (default) or `'bottom'`
2222
* @property offset - Extra spacing from screen edge in pixels (default: `0`)
23+
* @property rtl - Enable right-to-left layout (default: `false`)
2324
* @property stacking - Show multiple toasts stacked (default: `true`). When `false`, only one toast shows at a time
25+
* @property maxStack - Maximum visible toasts when stacking (default: `3`)
26+
* @property dismissible - Whether toasts can be swiped to dismiss (default: `true`)
27+
* @property showCloseButton - Show close button on toasts (default: `true`)
2428
* @property defaultDuration - Default display time in ms (default: `4000`)
29+
* @property deduplication - Deduplicate repeated toasts, resetting timer with pulse/shake animation (default: `false`)
2530
* @property colors - Customize colors per toast type (`success`, `error`, `info`, `loading`)
31+
* @property icons - Custom icons per toast type
2632
* @property toastStyle - Style overrides for the toast container (borderRadius, shadow, padding, etc.)
2733
* @property titleStyle - Style overrides for the title text
2834
* @property descriptionStyle - Style overrides for the description text

package/src/toast-store.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const DEFAULT_THEME: ToastTheme = {
2323
titleStyle: {},
2424
descriptionStyle: {},
2525
defaultDuration: 4000,
26+
deduplication: false,
2627
};
2728

2829
function mergeConfig(config: ToastConfig | undefined): ToastTheme {
@@ -55,6 +56,7 @@ function mergeConfig(config: ToastConfig | undefined): ToastTheme {
5556
titleStyle: { ...DEFAULT_THEME.titleStyle, ...config.titleStyle },
5657
descriptionStyle: { ...DEFAULT_THEME.descriptionStyle, ...config.descriptionStyle },
5758
defaultDuration: config.defaultDuration ?? DEFAULT_THEME.defaultDuration,
59+
deduplication: config.deduplication ?? DEFAULT_THEME.deduplication,
5860
};
5961
}
6062

@@ -104,12 +106,35 @@ class ToastStore {
104106
): string => {
105107
const actualDuration = duration ?? options?.duration ?? this.theme.defaultDuration;
106108
const maxToasts = this.theme.stacking ? this.theme.maxStack : 1;
109+
const resolvedDescription = description ?? options?.description;
110+
111+
const shouldDedup = type !== "loading" && (options?.deduplication ?? this.theme.deduplication);
112+
if (shouldDedup) {
113+
const deduplicationId = options?.id;
114+
const frontToast = this.state.visibleToasts.find(t => !t.isExiting);
115+
const duplicate = deduplicationId
116+
? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId)
117+
: frontToast && frontToast.title === title && frontToast.type === type && frontToast.description === resolvedDescription
118+
? frontToast
119+
: undefined;
120+
121+
if (duplicate) {
122+
this.updateToast(duplicate.id, {
123+
title,
124+
description: resolvedDescription,
125+
type,
126+
deduplicatedAt: Date.now(),
127+
duration: actualDuration,
128+
});
129+
return duplicate.id;
130+
}
131+
}
107132

108133
const id = `toast-${++this.toastIdCounter}`;
109134
const newToast: Toast = {
110135
id,
111136
title,
112-
description: description ?? options?.description,
137+
description: resolvedDescription,
113138
type,
114139
duration: actualDuration,
115140
createdAt: Date.now(),

package/src/toast.tsx

Lines changed: 62 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { memo, useCallback, useEffect, useState } from "react";
1+
import { memo, useCallback, useEffect, useRef, useState } from "react";
22
import { Pressable, StyleSheet, Text, View } from "react-native";
33
import { Gesture, GestureDetector } from "react-native-gesture-handler";
4-
import Animated, { interpolate, useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
4+
import Animated, {
5+
interpolate,
6+
useAnimatedStyle,
7+
useSharedValue,
8+
withSequence,
9+
withTiming,
10+
} from "react-native-reanimated";
511
import { useSafeAreaInsets } from "react-native-safe-area-context";
612
import { scheduleOnRN } from "react-native-worklets";
713
import {
14+
DEDUPLICATION_PULSE_DURATION,
15+
DEDUPLICATION_SHAKE_DURATION,
816
DISMISS_THRESHOLD,
917
DISMISS_VELOCITY_THRESHOLD,
1018
EASING,
@@ -42,12 +50,12 @@ export const ToastContainer = () => {
4250
})
4351
.onUpdate(event => {
4452
"worklet";
45-
if (!isDismissibleMutable.value) return;
46-
const ref = topToastMutable.value;
53+
if (!isDismissibleMutable.get()) return;
54+
const ref = topToastMutable.get();
4755
if (!ref) return;
4856

4957
const { slot } = ref;
50-
const bottom = isBottomMutable.value;
58+
const bottom = isBottomMutable.get();
5159
const rawY = event.translationY;
5260
const dismissDrag = bottom ? rawY : -rawY;
5361
const resistDrag = bottom ? -rawY : rawY;
@@ -70,17 +78,17 @@ export const ToastContainer = () => {
7078
})
7179
.onEnd(() => {
7280
"worklet";
73-
if (!isDismissibleMutable.value) return;
74-
const ref = topToastMutable.value;
81+
if (!isDismissibleMutable.get()) return;
82+
const ref = topToastMutable.get();
7583
if (!ref) return;
7684

7785
const { slot } = ref;
78-
const bottom = isBottomMutable.value;
79-
if (shouldDismiss.value) {
86+
const bottom = isBottomMutable.get();
87+
if (shouldDismiss.get()) {
8088
slot.progress.set(withTiming(0, { duration: EXIT_DURATION, easing: EASING }));
8189
const exitOffset = bottom ? SWIPE_EXIT_OFFSET : -SWIPE_EXIT_OFFSET;
8290
slot.translationY.set(
83-
withTiming(slot.translationY.value + exitOffset, { duration: EXIT_DURATION, easing: EASING })
91+
withTiming(slot.translationY.get() + exitOffset, { duration: EXIT_DURATION, easing: EASING })
8492
);
8593
scheduleOnRN(ref.dismiss);
8694
} else {
@@ -130,12 +138,14 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
130138

131139
const [wasLoading, setWasLoading] = useState(toast.type === "loading");
132140
const [showIcon, setShowIcon] = useState(false);
141+
const lastDeduplicatedAt = useRef(toast.deduplicatedAt);
133142

134143
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect
135144
useEffect(() => {
136145
slot.progress.set(0);
137146
slot.translationY.set(0);
138147
slot.stackIndex.set(index);
148+
slot.deduplication.set(0);
139149
slot.progress.set(withTiming(1, { duration: ENTRY_DURATION, easing: EASING }));
140150

141151
const iconTimeout = setTimeout(() => setShowIcon(true), 50);
@@ -192,29 +202,59 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
192202
dismissToast,
193203
]);
194204

205+
useEffect(() => {
206+
if (toast.deduplicatedAt && toast.deduplicatedAt !== lastDeduplicatedAt.current) {
207+
lastDeduplicatedAt.current = toast.deduplicatedAt;
208+
209+
if (toast.type === "error") {
210+
const step = DEDUPLICATION_SHAKE_DURATION / 8;
211+
slot.deduplication.set(0);
212+
slot.deduplication.set(withSequence(
213+
withTiming(6, { duration: step }),
214+
withTiming(-5, { duration: step }),
215+
withTiming(4, { duration: step }),
216+
withTiming(-3, { duration: step }),
217+
withTiming(2, { duration: step }),
218+
withTiming(-1, { duration: step }),
219+
withTiming(0, { duration: step * 2 })
220+
));
221+
} else {
222+
slot.deduplication.set(0);
223+
slot.deduplication.set(withSequence(
224+
withTiming(1, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING }),
225+
withTiming(0, { duration: DEDUPLICATION_PULSE_DURATION / 2, easing: EASING })
226+
));
227+
}
228+
}
229+
}, [toast.deduplicatedAt, toast.type, slot]);
230+
195231
const shouldAnimateIcon = wasLoading && toast.type !== "loading";
232+
const isErrorType = toast.type === "error";
196233

197234
const animatedStyle = useAnimatedStyle(() => {
198-
const baseTranslateY = interpolate(slot.progress.value, [0, 1], [entryFromY, 0]);
235+
const baseTranslateY = interpolate(slot.progress.get(), [0, 1], [entryFromY, 0]);
199236
const stackOffsetY = isBottom
200-
? slot.stackIndex.value * STACK_OFFSET_PER_ITEM
201-
: slot.stackIndex.value * -STACK_OFFSET_PER_ITEM;
202-
const stackScale = 1 - slot.stackIndex.value * STACK_SCALE_PER_ITEM;
237+
? slot.stackIndex.get() * STACK_OFFSET_PER_ITEM
238+
: slot.stackIndex.get() * -STACK_OFFSET_PER_ITEM;
239+
const stackScale = 1 - slot.stackIndex.get() * STACK_SCALE_PER_ITEM;
203240

204-
const finalTranslateY = baseTranslateY + slot.translationY.value + stackOffsetY;
241+
const finalTranslateY = baseTranslateY + slot.translationY.get() + stackOffsetY;
205242

206-
const progressOpacity = interpolate(slot.progress.value, [0, 1], [0, 1]);
207-
const dismissDirection = isBottom ? slot.translationY.value : -slot.translationY.value;
243+
const progressOpacity = interpolate(slot.progress.get(), [0, 1], [0, 1]);
244+
const dismissDirection = isBottom ? slot.translationY.get() : -slot.translationY.get();
208245
const dragOpacity = dismissDirection > 0 ? interpolate(dismissDirection, [0, 130], [1, 0], "clamp") : 1;
209246
const opacity = progressOpacity * dragOpacity;
210247

211-
const dragScale = interpolate(Math.abs(slot.translationY.value), [0, 50], [1, 0.98], "clamp");
212-
const scale = stackScale * dragScale;
248+
const dedupVal = slot.deduplication.get();
249+
const dragScale = interpolate(Math.abs(slot.translationY.get()), [0, 50], [1, 0.98], "clamp");
250+
const pulseScale = isErrorType ? 1 : 1 + dedupVal * 0.03;
251+
const scale = stackScale * dragScale * pulseScale;
252+
const shakeTranslateX = isErrorType ? dedupVal : 0;
213253

214254
return {
215-
transform: [{ translateY: finalTranslateY }, { scale }],
255+
transform: [{ translateY: finalTranslateY }, { translateX: shakeTranslateX }, { scale }],
216256
opacity,
217-
zIndex: 1000 - Math.round(slot.stackIndex.value),
257+
zIndex: 1000 - Math.round(slot.stackIndex.get()),
218258
};
219259
});
220260

@@ -320,6 +360,7 @@ const MemoizedToastItem = memo(ToastItem, (prev, next) => {
320360
prev.toast.title === next.toast.title &&
321361
prev.toast.description === next.toast.description &&
322362
prev.toast.isExiting === next.toast.isExiting &&
363+
prev.toast.deduplicatedAt === next.toast.deduplicatedAt &&
323364
prev.index === next.index &&
324365
prev.position === next.position &&
325366
prev.theme === next.theme &&

0 commit comments

Comments
 (0)