React Native has no built-in toast
Worth saying plainly, because it surprises people coming from Android: React Native core ships ToastAndroidand nothing else. On iOS there is no system toast at all, what you see in iOS apps is either a native banner (the same presentation style as a notification) or a view the app draws itself. So “add a toast” is always a decision about which of those two you want.
The split matters more than the library name. A JS view lives inside your React tree: fully stylable, themeable, testable, and coverable by anything that renders above it. A native toast is drawn by the OS in its own window: it can never be covered, and you can barely style it. If you want to see which way shipping apps lean, the notification screens from live iOS apps on mobileappdesign show both styles side by side, with the revenue behind each app.
The four options, honestly
| Option | Type | Platforms | Pick it for | The cost |
|---|---|---|---|---|
react-native-toast-message | JS view | iOS · Android · Web | Full control over the toast component, imperative API from anywhere | You style everything and handle safe area yourself |
sonner-native | JS view | iOS · Android | Stacking, swipe-to-dismiss, and promise toasts out of the box | Requires Reanimated and gesture-handler; opinionated look |
burnt | Native | iOS · Android | Real native iOS banner and Android toast, feels like the OS, not your app | Almost no styling control; needs a dev build, not Expo Go |
ToastAndroid (core) | Native | Android only | Zero dependencies, impossible to cover with your own views | Silently does nothing on iOS |
DIY + Reanimated | JS view | iOS · Android · Web | No dependency, exactly your design system | You own queueing, timers, gestures, and accessibility |
If you have no strong opinion, use react-native-toast-message. It is pure JavaScript, so it survives Expo Go and React Native Web, the imperative API can be called from a network layer that has no access to React context, and the toast body is your own component, which means it inherits your design tokens instead of fighting them.
The working setup
Two files. A config that defines what a toast looks like, and one mount at the very root of the app, after the navigator, never inside a screen.
// toast-config.tsx
import { Text, View, StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
function Base({ text1, text2, accent }) {
// The toast host is NOT inside your SafeAreaView, so it inherits
// no inset. Read it here or the notch eats the first line.
const insets = useSafeAreaInsets();
return (
<View style={[styles.card, { marginTop: insets.top + 8, borderLeftColor: accent }]}>
<Text style={styles.title} numberOfLines={1}>{text1}</Text>
{text2 ? <Text style={styles.body} numberOfLines={2}>{text2}</Text> : null}
</View>
);
}
export const toastConfig = {
success: (props) => <Base {...props} accent="#22c55e" />,
error: (props) => <Base {...props} accent="#ef4444" />,
info: (props) => <Base {...props} accent="#f97316" />,
};
const styles = StyleSheet.create({
card: {
width: '92%',
borderRadius: 12,
borderLeftWidth: 4,
paddingVertical: 12,
paddingHorizontal: 14,
backgroundColor: '#242424',
// Android needs elevation, iOS needs shadow*, set both.
elevation: 6,
shadowColor: '#000',
shadowOpacity: 0.3,
shadowRadius: 12,
shadowOffset: { width: 0, height: 4 },
},
title: { color: '#fff', fontSize: 15, fontWeight: '600' },
body: { color: 'rgba(255,255,255,0.7)', fontSize: 13, marginTop: 2 },
});// app/_layout.tsx (Expo Router)
import { Stack } from 'expo-router';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import Toast from 'react-native-toast-message';
import { toastConfig } from '../toast-config';
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack />
{/* Last child of the root. Mounted once, outliving every screen. */}
<Toast config={toastConfig} topOffset={0} />
</SafeAreaProvider>
);
}Now anything, a screen, a mutation hook, an interceptor in your API client, can call it without props or context:
import Toast from 'react-native-toast-message';
Toast.show({ type: 'success', text1: 'Saved', visibilityTime: 2000 });
Toast.show({
type: 'error',
text1: 'Could not sync',
text2: 'We will retry when you are back online.',
visibilityTime: 4000,
});Three reasons your toast fires but nobody sees it
These are the bugs that eat an afternoon, and none of them throw an error.
- The host is mounted inside a screen. If
<Toast />lives in a screen component, navigating away unmounts it mid-animation. The classic symptom is that the toast works when you stay put and silently fails on the one flow that matters, save-then-go-back. Mount it once, at the root, as the last child. - A Modal is above it. React Native’s
Modalis not a view in your tree. It opens a separate native window that sits above the entire React root, toast host included. Nothing you do withzIndexwill fix it. Either avoid firing toasts while a Modal is open, switch that Modal to a router modal route or a bottom sheet (both of which stay inside your tree), or fall back to a native toast for that one case. The modal guide covers which kind you actually want. - It fired during a transition. Calling
Toast.show()in the same tick as anavigation.goBack()races the screen animation, and on Android the entrance animation often gets dropped. Fire it from the destination instead, or defer withInteractionManager.runAfterInteractions(() => Toast.show(...)), which waits for the transition to settle.
Rolling your own with Reanimated
Worth doing when your design system is strict and you only need one toast at a time. The whole thing is a shared value, a timer, and an absolutely-positioned view:
import { createContext, useCallback, useContext, useRef, useState } from 'react';
import { AccessibilityInfo, StyleSheet, Text } from 'react-native';
import Animated, { FadeInUp, FadeOutUp } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const ToastContext = createContext((msg: string) => {});
export const useToast = () => useContext(ToastContext);
export function ToastProvider({ children }) {
const [message, setMessage] = useState<string | null>(null);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const insets = useSafeAreaInsets();
const show = useCallback((msg: string) => {
// Replace, do not queue, a queue of stale toasts is worse than none.
if (timer.current) clearTimeout(timer.current);
setMessage(msg);
// Screen readers do not announce a view appearing. Say it out loud.
AccessibilityInfo.announceForAccessibility(msg);
timer.current = setTimeout(() => setMessage(null), 2600);
}, []);
return (
<ToastContext.Provider value={show}>
{children}
{message && (
<Animated.View
entering={FadeInUp.duration(180)}
exiting={FadeOutUp.duration(140)}
pointerEvents="none"
accessibilityLiveRegion="polite"
style={[styles.toast, { top: insets.top + 12 }]}
>
<Text style={styles.text}>{message}</Text>
</Animated.View>
)}
</ToastContext.Provider>
);
}
const styles = StyleSheet.create({
toast: {
position: 'absolute',
left: 16,
right: 16,
padding: 14,
borderRadius: 12,
backgroundColor: '#242424',
elevation: 6,
},
text: { color: '#fff', fontSize: 15 },
});Two details that separate this from the version most people ship. pointerEvents="none" stops the toast swallowing taps on whatever it floats over, without it, a toast over a list makes the top row unclickable for three seconds. And announceForAccessibility matters because VoiceOver and TalkBack do not narrate a view that simply appears; a purely visual toast is invisible to a screen-reader user, which is exactly the audience least able to guess that the save worked.
When not to use a toast
The failure mode of a good toast component is that it becomes the answer to everything. A rule that holds up: a toast is for something the user is allowed to miss.
- Form validation belongs under the field that failed, not in a banner that vanishes before the user finishes reading it.
- Destructive confirmation belongs in an
Alert: a toast cannot be acknowledged. - Anything with an action(“Undo”) needs a longer timeout and
pointerEvents="auto", and it needs to survive a navigation. If you find yourself building that, you want a snackbar, not a toast. - Offline and sync state is a persistent condition, not an event. Use a bar that stays until the condition clears.
Skip the wiring
Toast host at the root, safe-area insets, an accessible announcement, a themed toast body. It is maybe forty lines, and it is forty lines you rewrite in every project. ShipNative generates a real React Native app from a description with this scaffolding already in place, previews it on your phone, and hands you the full Expo project to keep. Describe the app in a sentence and edit the toast component like any other file you wrote.