Toast

A fully featured toast system with a global API and stacked animations

Last updated on

Edit on GitHub

Manual

Install the following dependencies:

npm install react-native-reanimated react-native-worklets react-native-safe-area-context

Copy 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