Toast
A fully featured toast system with a global API and stacked animations
Last updated on
Manual
Install the following dependencies:
npm install react-native-reanimated react-native-worklets react-native-safe-area-contextCopy and paste the following code into your project.
component/molecules/toast/index
import * as React from "react";import { ToastProvider, useToast } from "./context/ToastContext";import { ToastViewport } from "./ToastViewPort";import type { ToastOptions, ToastProps } from "./Toast.types";type ToastRef = { show?: (content: React.ReactNode | string, options?: ToastOptions) => string; update?: ( id: string, content: React.ReactNode | string, options?: ToastOptions, ) => void; dismiss?: (id: string) => void; dismissAll?: () => void;};const toastRef: ToastRef = {};const ToastController: React.FC = () => { const toast = useToast(); toastRef.show = toast.show; toastRef.update = toast.update; toastRef.dismiss = toast.dismiss; toastRef.dismissAll = toast.dismissAll; return null;};export const ToastProviderWithViewport: React.FC<ToastProps> = ({ children,}) => { return ( <ToastProvider> <ToastController /> {children} <ToastViewport /> </ToastProvider> );};export const Toast = { show: (content: React.ReactNode | string, options?: ToastOptions): string => { if (!toastRef.show) { console.error( "Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.", ); return ""; } return toastRef.show(content, options); }, update: ( id: string, content: React.ReactNode | string, options?: ToastOptions, ): void => { if (!toastRef.update) { console.error( "Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.", ); return; } return toastRef.update(id, content, options); }, dismiss: (id: string): void => { if (!toastRef.dismiss) { console.error( "Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.", ); return; } return toastRef.dismiss(id); }, dismissAll: (): void => { if (!toastRef.dismissAll) { console.error( "Toast provider not initialized. Make sure you have wrapped your app with ToastProviderWithViewport.", ); return; } return toastRef.dismissAll(); },};export { ToastProvider, useToast } from "./context/ToastContext";export type { ToastOptions, ToastType, ToastPosition } from "./Toast.types";Copy and paste the following code into your project.
component/molecules/toast/Toast
import { useToast } from "./context/ToastContext";import type { Toast as ToastType, ToastType as ToastVariant,} from "./Toast.types";import React, { useEffect, useRef } from "react";import { LayoutAnimation, Platform, Pressable, StyleSheet, Text, TouchableOpacity, UIManager, View,} from "react-native";import Animated, { Easing, useAnimatedStyle, useSharedValue, withSpring, withTiming,} from "react-native-reanimated";import { scheduleOnRN } from "react-native-worklets";if (Platform.OS === "android") { if (UIManager.setLayoutAnimationEnabledExperimental) { UIManager.setLayoutAnimationEnabledExperimental(true); }}interface ToastProps { toast: ToastType; index: number; onHeightChange?: (id: string, height: number) => void;}const getBackgroundColor = (type: ToastVariant) => { switch (type) { case "success": return "#10B981"; case "error": return "#EF4444"; case "warning": return "#F59E0B"; case "info": return "#3B82F6"; default: return "#262626"; }};const getIconForType = (type: ToastVariant) => { switch (type) { case "success": return "✓"; case "error": return "✗"; case "warning": return "⚠"; case "info": return "ℹ"; default: return ""; }};export const Toast: React.FC<ToastProps> = ({ toast, index }) => { const prevContentRef = useRef<string | React.ReactNode | null>(null); const prevTypeRef = useRef<ToastVariant | null>(null); const prevIndexRef = useRef<number>(-1); const { dismiss, expandedToasts, expandToast, collapseToast } = useToast(); const opacity = useSharedValue<number>(1); const translateY = useSharedValue<number>( toast.options.position === "top" ? -100 : 100, ); const scale = useSharedValue<number>(0.9); const rotateZ = useSharedValue<number>(0); const height = useSharedValue<number>(0); const expandHeight = useSharedValue<number>(0); const viewRef = useRef<View>(null); const isExpanded = expandedToasts.has(toast.id); const hasExpandedContent = !!toast.options.expandedContent; const getStackOffset = () => { const baseOffset = 4; const maxOffset = 12; const offset = Math.min(index * baseOffset, maxOffset); return toast.options.position === "top" ? offset : -offset; }; const getStackScale = () => { const scaleReduction = 0.02; const minScale = 0.92; return Math.max(1 - index * scaleReduction, minScale); }; useEffect(() => { if (prevIndexRef.current !== index && opacity.value > 0) { const soonerOffset = toast.options.position === "top" ? 2 : -2; translateY.value = withTiming(getStackOffset() + soonerOffset, { duration: 400, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); scale.value = withTiming(getStackScale() * 0.98, { duration: 400, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); setTimeout(() => { translateY.value = withSpring(getStackOffset(), { damping: 25, stiffness: 120, mass: 0.8, velocity: 0, }); scale.value = withSpring(getStackScale(), { damping: 25, stiffness: 120, mass: 0.8, velocity: 0, }); }, 200); } prevIndexRef.current = index; }, [index, toast.options.position, translateY, scale, opacity]); const handleDismiss = () => { dismiss(toast.id); toast.options.onClose?.(); }; const animatedDismiss = () => { opacity.value = withTiming(0, { duration: 300, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); translateY.value = withTiming(toast.options.position === "top" ? -50 : 50, { duration: 300, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); scale.value = withTiming(0.85, { duration: 300, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); setTimeout(() => { handleDismiss(); }, 300); }; useEffect(() => { const delay = index * 50; LayoutAnimation.configureNext({ duration: 300, create: { type: LayoutAnimation.Types.easeInEaseOut, property: LayoutAnimation.Properties.opacity, }, update: { type: LayoutAnimation.Types.easeInEaseOut, }, }); setTimeout(() => { // opacity.value = withTiming(1, { // duration: 500, // easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), // }); translateY.value = withSpring(getStackOffset(), { damping: 28, stiffness: 140, mass: 0.8, velocity: 0, }); scale.value = withSpring(getStackScale(), { damping: 28, stiffness: 140, mass: 0.8, velocity: 0, }); rotateZ.value = withTiming(0, { duration: 500, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); }, delay); if (toast.options.duration > 0) { const exitDelay = Math.max(0, toast.options.duration - 500); const exitAnimations = () => { opacity.value = withTiming(0, { duration: 400, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); translateY.value = withTiming( toast.options.position === "top" ? 20 : 20, { duration: 400, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }, ); scale.value = withTiming(0.95, { duration: 400, easing: Easing.bezier(0.25, 0.46, 0.45, 0.94), }); setTimeout(() => { scheduleOnRN(handleDismiss); }, 400); }; setTimeout(exitAnimations, exitDelay); } }, [toast, opacity, translateY, scale, rotateZ, index]); // Animate expansion useEffect(() => { if (isExpanded && hasExpandedContent) { expandHeight.value = withSpring(1, { damping: 20, stiffness: 100, }); } else { expandHeight.value = withSpring(0, { damping: 20, stiffness: 100, }); } }, [isExpanded, hasExpandedContent, expandHeight]); const animatedStyle = useAnimatedStyle(() => { return { opacity: opacity.value, transform: [ { translateY: translateY.value }, { scale: scale.value }, { rotateZ: `${rotateZ.value}deg` }, ], zIndex: 1000 - index, }; }); const expandedContentStyle = useAnimatedStyle(() => { return { maxHeight: expandHeight.value * 300, opacity: expandHeight.value, }; }); const handlePress = () => { if (!hasExpandedContent) { return; } if (isExpanded) { collapseToast(toast.id); } else { expandToast(toast.id); } }; const backgroundColor = toast.options.backgroundColor ?? getBackgroundColor(toast.options.type); const _styles = toast.options?.style || {}; const icon = getIconForType(toast.options.type); const renderExpandedContent = () => { if (!hasExpandedContent) return null; const content = toast.options.expandedContent; if (typeof content === "function") { return content({ dismiss: animatedDismiss }); } return content; }; return ( <Animated.View style={[ styles.toastContainer, animatedStyle, { marginTop: 0, marginBottom: 0, position: "absolute", top: toast.options.position === "top" ? 80 : undefined, bottom: toast.options.position === "bottom" ? 0 : undefined, }, _styles, ]} > <Pressable style={[styles.toast, { backgroundColor }]} onPress={handlePress} android_ripple={{ color: "rgba(255, 255, 255, 0.1)" }} > <View style={styles.mainContent}> {icon ? <Text style={styles.icon}>{icon}</Text> : null} <View style={styles.contentContainer}> {typeof toast.content === "string" ? ( <Text style={styles.text}>{toast.content}</Text> ) : ( toast.content )} </View> {toast.options.action && ( <TouchableOpacity style={styles.actionButton} onPress={() => { toast?.options?.action?.onPress!(); animatedDismiss(); }} > <Text style={styles.actionText}> {toast.options.action.label} </Text> </TouchableOpacity> )} </View> {/* Expanded Content */} {hasExpandedContent && ( <Animated.View style={[styles.expandedContent, expandedContentStyle]}> {renderExpandedContent()} </Animated.View> )} </Pressable> </Animated.View> );};const styles = StyleSheet.create({ toastContainer: { width: "90%", maxWidth: 400, alignSelf: "center", marginVertical: 4, borderRadius: 100, overflow: "hidden", shadowColor: "#000", shadowOffset: { width: 0, height: 4, }, shadowOpacity: 0.2, shadowRadius: 8, elevation: 8, }, toast: { flexDirection: "column", borderRadius: 12, }, mainContent: { flexDirection: "row", alignItems: "center", padding: 16, }, icon: { color: "#fff", fontSize: 20, marginRight: 12, fontWeight: "bold", textAlign: "center", width: 24, }, contentContainer: { flex: 1, }, text: { color: "#fff", fontSize: 16, fontWeight: "500", lineHeight: 20, }, actionButton: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: 6, backgroundColor: "rgba(255, 255, 255, 0.2)", marginLeft: 12, }, actionText: { color: "#fff", fontSize: 14, fontWeight: "600", }, expandedContent: { overflow: "hidden", },});Copy and paste the following code into your project.
component/molecules/toast/ToastViewPort
import { useToast } from "./context/ToastContext";import React from "react";import { StyleSheet, View } from "react-native";import { useSafeAreaInsets } from "react-native-safe-area-context";import { Toast } from "./Toast";export const ToastViewport: React.FC = () => { const { toasts } = useToast(); const insets = useSafeAreaInsets(); const topToasts = toasts.filter((toast) => toast.options.position === "top"); const bottomToasts = toasts.filter( (toast) => toast.options.position === "bottom", ); return ( <> <View style={[ styles.viewport, styles.topViewport, { paddingTop: insets.top + 10, height: 200, }, ]} > {topToasts.map((toast, arrayIndex) => { const displayIndex = topToasts.length - 1 - arrayIndex; return <Toast key={toast.id} toast={toast} index={displayIndex} />; })} </View> <View style={[ styles.viewport, styles.bottomViewport, { marginBottom: insets.bottom, height: 200, }, ]} > {bottomToasts.map((toast, arrayIndex) => { const displayIndex = bottomToasts.length - 1 - arrayIndex; return <Toast key={toast.id} toast={toast} index={displayIndex} />; })} </View> </> );};const styles = StyleSheet.create({ viewport: { position: "absolute", left: 0, right: 0, zIndex: 9999, paddingHorizontal: 16, pointerEvents: "box-none", }, topViewport: { top: 0, justifyContent: "flex-start", }, bottomViewport: { bottom: 0, justifyContent: "flex-end", },});Copy and paste the following code into your project.
component/molecules/toast/ToastContext
import type { Toast, ToastContextValue, ToastOptions } from "../Toast.types";import React, { createContext, useCallback, useContext, useEffect, useState,} from "react";const DEFAULT_TOAST_OPTIONS: Required<ToastOptions> = { duration: 3000, type: "default", position: "bottom", backgroundColor: "#262626", onClose: () => {}, action: null, expandedContent: null, style: {},};const ToastContext = createContext<ToastContextValue | undefined>(undefined);export const useToast = (): ToastContextValue => { const context = useContext(ToastContext); if (!context) { throw new Error("useToast must be used within a ToastProvider"); } return context;};export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children,}) => { const [toasts, setToasts] = useState<Toast[]>([]); const [expandedToasts, setExpandedToasts] = useState<Set<string>>(new Set()); const show = useCallback( (content: React.ReactNode | string, options?: ToastOptions): string => { const id = Math.random().toString(36).substring(2, 9); const toast: Toast = { id, content, options: { ...DEFAULT_TOAST_OPTIONS, ...options, }, }; setToasts((prevToasts) => [...prevToasts, toast]); return id; }, [], ); const update = useCallback( (id: string, content: React.ReactNode | string, options?: ToastOptions) => { setToasts((prevToasts) => prevToasts.map((toast) => toast.id === id ? { ...toast, content, options: { ...toast.options, ...options, }, } : toast, ), ); }, [], ); const dismiss = useCallback((id: string) => { setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); setExpandedToasts((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); }, []); const dismissAll = useCallback(() => { setToasts([]); setExpandedToasts(new Set()); }, []); const expandToast = useCallback((id: string) => { setExpandedToasts((prev) => { const newSet = new Set(prev); if (newSet.size >= 3 && !newSet.has(id)) { const firstId = Array.from(newSet)[0]; newSet.delete(firstId); } newSet.add(id); return newSet; }); }, []); const collapseToast = useCallback((id: string) => { setExpandedToasts((prev) => { const newSet = new Set(prev); newSet.delete(id); return newSet; }); }, []); useEffect(() => { if (toasts.length === 0) return; const timeouts: NodeJS.Timeout[] = []; toasts.forEach((toast) => { if (toast.options.duration > 0) { const timeout = setTimeout(() => { dismiss(toast.id); toast.options.onClose?.(); }, toast.options.duration); timeouts.push(timeout as any); } }); return () => { timeouts.forEach(clearTimeout); }; }, [toasts, dismiss]); const value: ToastContextValue = { toasts, show, update, dismiss, dismissAll, expandedToasts, expandToast, collapseToast, }; return ( <ToastContext.Provider value={value}>{children}</ToastContext.Provider> );};Usage
import { View, Text, StyleSheet, Pressable } from "react-native";import { GestureHandlerRootView } from "react-native-gesture-handler";import { StatusBar } from "expo-status-bar";import { useFonts } from "expo-font";import { SymbolView } from "expo-symbols";import { ToastProviderWithViewport, useToast } from "@/components";const CustomToast = ({ title, message,}: { title: string; message: string;}) => { return ( <View style={toastStyles.container}> <View style={toastStyles.iconBox}> <SymbolView name="bell.fill" size={16} tintColor="#ffffff" /> </View> <View style={toastStyles.content}> <Text style={toastStyles.title}>{title}</Text> <Text style={toastStyles.message}>{message}</Text> </View> </View> );};const toastStyles = StyleSheet.create({ container: { flexDirection: "row", alignItems: "center", gap: 12, }, iconBox: { width: 36, height: 36, borderRadius: 100, backgroundColor: "rgba(255,255,255,0.15)", justifyContent: "center", alignItems: "center", }, content: { flex: 1, gap: 2, }, title: { fontSize: 14, fontWeight: "600", color: "#fff", }, message: { fontSize: 12, color: "rgba(255,255,255,0.7)", },});function AppContent() { const [fontLoaded] = useFonts({ SfProRounded: require("@/assets/fonts/sf-pro-rounded.ttf"), HelveticaNowDisplay: require("@/assets/fonts/HelveticaNowDisplayMedium.ttf"), }); const toast = useToast(); const showCustomToast = () => { toast.show( <CustomToast title="New Message" message="Sarah sent you a photo." />, { duration: 5000, position: "top", type: "default", backgroundColor: "#1c1c1c", }, ); }; const showRandomToast = () => { const TOAST_STRING = [ { title: `Synced data sucessfully.`, type: "success", backgroundColor: "#1ad41d", }, { title: `Failed to load data from server.`, type: "error", backgroundColor: "#ff4545", }, { title: `Deprecation alert for your API usage.`, type: "warning", backgroundColor: "#ef932a", }, ]; const randomIndex = Math.floor(Math.random() * TOAST_STRING.length); toast.show(TOAST_STRING[randomIndex].title, { type: TOAST_STRING[randomIndex].type as any, backgroundColor: TOAST_STRING[randomIndex].backgroundColor, position: "top", }); }; return ( <GestureHandlerRootView style={styles.container}> <StatusBar style="light" /> <View style={styles.content}> <Text style={[ styles.title, fontLoaded && { fontFamily: "HelveticaNowDisplay" }, ]} > Notifications </Text> <View style={{ alignItems: "center" }}> <Pressable style={styles.button} onPress={showCustomToast}> <SymbolView name="bell.badge.fill" size={20} tintColor="#000000" /> <Text style={[ styles.buttonText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Show Notification </Text> </Pressable> <Pressable style={[ styles.button, { marginTop: 10, }, ]} onPress={showRandomToast} > <SymbolView name="gear" size={20} tintColor="#000000" /> <Text style={[ styles.buttonText, fontLoaded && { fontFamily: "SfProRounded" }, ]} > Custom Toast </Text> </Pressable> </View> </View> </GestureHandlerRootView> );}export default function App() { return ( <ToastProviderWithViewport> <AppContent /> </ToastProviderWithViewport> );}const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: "#0a0a0a", }, content: { flex: 1, paddingHorizontal: 20, paddingTop: 100, gap: 32, }, title: { fontSize: 32, fontWeight: "700", color: "#fff", }, button: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: 10, backgroundColor: "#fff", paddingVertical: 16, borderRadius: 16, width: 300, }, buttonText: { fontSize: 16, fontWeight: "600", color: "#000", },});Props
ICarouselItem
ICarouselRenderItem
React Native Reanimated
React Native Worklets
React Native Safe Area Context
