Skip to content

Commit 746587f

Browse files
committed
feat: deduplication toasts with nice animation
1 parent 4aaa34e commit 746587f

9 files changed

Lines changed: 154 additions & 5 deletions

File tree

README.md

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

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

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

210242
| Method | Description |

example/react-native-bread-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/react-native-bread-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>

packages/react-native-bread/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);

packages/react-native-bread/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 }, () => ({

packages/react-native-bread/src/toast-provider.tsx

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

packages/react-native-bread/src/toast-store.ts

Lines changed: 25 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,34 @@ 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 duplicate = deduplicationId
115+
? this.state.visibleToasts.find(t => !t.isExiting && t.options?.id === deduplicationId)
116+
: this.state.visibleToasts.find(
117+
t => !t.isExiting && t.title === title && t.type === type && t.description === resolvedDescription
118+
);
119+
120+
if (duplicate) {
121+
this.updateToast(duplicate.id, {
122+
title,
123+
description: resolvedDescription,
124+
type,
125+
deduplicatedAt: Date.now(),
126+
duration: actualDuration,
127+
});
128+
return duplicate.id;
129+
}
130+
}
107131

108132
const id = `toast-${++this.toastIdCounter}`;
109133
const newToast: Toast = {
110134
id,
111135
title,
112-
description: description ?? options?.description,
136+
description: resolvedDescription,
113137
type,
114138
duration: actualDuration,
115139
createdAt: Date.now(),

packages/react-native-bread/src/toast.tsx

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1-
import { memo, useCallback, useEffect, useMemo, useState } from "react";
1+
import { memo, useCallback, useEffect, useMemo, 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,
@@ -135,12 +143,14 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
135143

136144
const [wasLoading, setWasLoading] = useState(toast.type === "loading");
137145
const [showIcon, setShowIcon] = useState(false);
146+
const lastDeduplicatedAt = useRef(toast.deduplicatedAt);
138147

139148
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only effect
140149
useEffect(() => {
141150
slot.progress.value = 0;
142151
slot.translationY.value = 0;
143152
slot.stackIndex.value = index;
153+
slot.deduplication.value = 0;
144154
slot.progress.value = withTiming(1, { duration: ENTRY_DURATION, easing: EASING });
145155

146156
const iconTimeout = setTimeout(() => setShowIcon(true), 50);
@@ -197,7 +207,34 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
197207
dismissToast,
198208
]);
199209

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

202239
const animatedStyle = useAnimatedStyle(() => {
203240
const baseTranslateY = interpolate(slot.progress.value, [0, 1], [entryFromY, 0]);
@@ -213,11 +250,14 @@ const ToastItem = ({ toast, index, theme, position, isTopToast, registerTopToast
213250
const dragOpacity = dismissDirection > 0 ? interpolate(dismissDirection, [0, 130], [1, 0], "clamp") : 1;
214251
const opacity = progressOpacity * dragOpacity;
215252

253+
const dedupVal = slot.deduplication.value;
216254
const dragScale = interpolate(Math.abs(slot.translationY.value), [0, 50], [1, 0.98], "clamp");
217-
const scale = stackScale * dragScale;
255+
const pulseScale = isErrorType ? 1 : 1 + dedupVal * 0.03;
256+
const scale = stackScale * dragScale * pulseScale;
257+
const shakeTranslateX = isErrorType ? dedupVal : 0;
218258

219259
return {
220-
transform: [{ translateY: finalTranslateY }, { scale }],
260+
transform: [{ translateY: finalTranslateY }, { translateX: shakeTranslateX }, { scale }],
221261
opacity,
222262
zIndex: 1000 - Math.round(slot.stackIndex.value),
223263
};
@@ -325,6 +365,7 @@ const MemoizedToastItem = memo(ToastItem, (prev, next) => {
325365
prev.toast.title === next.toast.title &&
326366
prev.toast.description === next.toast.description &&
327367
prev.toast.isExiting === next.toast.isExiting &&
368+
prev.toast.deduplicatedAt === next.toast.deduplicatedAt &&
328369
prev.index === next.index &&
329370
prev.position === next.position &&
330371
prev.theme === next.theme &&

packages/react-native-bread/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,14 @@ export interface ToastTheme {
6565
descriptionStyle: TextStyle;
6666
/** Default duration in ms for toasts (default: 4000) */
6767
defaultDuration: number;
68+
/** When true, duplicate toasts reset the timer and play a feedback animation instead of stacking. Matches by title+type+description, or by `id` if provided. (default: false) */
69+
deduplication: boolean;
6870
}
6971

7072
/** Per-toast options for customizing individual toasts */
7173
export interface ToastOptions {
74+
/** 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. */
75+
id?: string;
7276
/** Description text */
7377
description?: string;
7478
/** Duration in ms (overrides default) */
@@ -91,6 +95,8 @@ export interface ToastOptions {
9195
* Receives props: { id, dismiss, type, isExiting }
9296
*/
9397
customContent?: ReactNode | CustomContentRenderFn;
98+
/** 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. */
99+
deduplication?: boolean;
94100
}
95101

96102
/** Configuration options for customizing toast behavior and appearance. All properties are optional. */
@@ -110,6 +116,7 @@ export interface Toast {
110116
duration: number;
111117
createdAt: number;
112118
isExiting?: boolean;
119+
deduplicatedAt?: number;
113120
/** Per-toast style/icon overrides */
114121
options?: ToastOptions;
115122
}

0 commit comments

Comments
 (0)