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.
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.