Skip to content
Native Animation

React Native Toast Notification with Queue and Swipe

By Native Animation · Updated

A React Native toast notification should confirm a small event without taking over the screen. This tutorial builds a bounded, first-in-first-out queue with an animated notification, swipe dismissal, a visible dismiss button, and safe-area positioning. React owns which message is active; Reanimated owns its movement, so dragging does not rerender the queue on every frame.

What you'll build

Press Save draft to enqueue a message. Only the first notification is visible, and dismissing it reveals the next. Each item has its own ID, so an old callback cannot remove a newer message. The queue keeps at most five entries including the active one. The timeout pauses while a gesture is active, while the dismiss button has keyboard focus, when the application is backgrounded, or when a screen reader is enabled.

This is in-app feedback. It does not schedule operating-system push notifications or deliver messages while the app is closed. Use persistent inline feedback for errors requiring recovery, and reserve a toast for confirmations that make sense if the user misses them.

Prerequisites

The examples use Expo SDK 57, React Native 0.86.3, Reanimated 4.5.1, and react-native-worklets 0.10.1. Gesture examples use Gesture Handler ~2.32.0. Resolve compatible versions with Expo rather than choosing the latest npm release:

npx expo install react-native-reanimated react-native-worklets react-native-gesture-handler

Use bunx expo install if your project uses Bun. The SDK 57 Reanimated documentation explains the automatic Babel setup. Keep gesture screens inside a GestureHandlerRootView as shown in the sample; if your application already provides one, reuse that root. These examples belong in a component file outside the Expo Router route tree. Render the exported example from your screen.

Build the component

Create QueuedToast.tsx. The file includes the reusable component and a small usage example.

import { useCallback, useEffect, useRef, useState } from 'react';
import {
  AccessibilityInfo,
  AppState,
  Pressable,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import {
  Gesture,
  GestureDetector,
  GestureHandlerRootView,
} from 'react-native-gesture-handler';
import Animated, {
  cancelAnimation,
  useAnimatedStyle,
  useReducedMotion,
  useSharedValue,
  withSpring,
  withTiming,
} from 'react-native-reanimated';
import { scheduleOnRN } from 'react-native-worklets';

type Toast = { id: number; message: string };
type ToastHostProps = {
  toast: Toast;
  topInset: number;
  leftInset: number;
  rightInset: number;
  onDismiss: (id: number) => void;
};

function ToastHost({
  toast,
  topInset,
  leftInset,
  rightInset,
  onDismiss,
}: ToastHostProps) {
  const reducedMotion = useReducedMotion();
  const x = useSharedValue(0);
  const origin = useSharedValue(0);
  const exiting = useSharedValue(false);
  const opacity = useSharedValue(reducedMotion ? 1 : 0);
  const [focused, setFocused] = useState(false);
  const [screenReader, setScreenReader] = useState(true);
  const [active, setActive] = useState(AppState.currentState === 'active');
  const [dragging, setDragging] = useState(false);

  const dismiss = useCallback(() => onDismiss(toast.id), [onDismiss, toast.id]);

  useEffect(() => {
    let mounted = true;
    AccessibilityInfo.isScreenReaderEnabled().then((enabled) => {
      if (mounted) setScreenReader(enabled);
    });
    const reader = AccessibilityInfo.addEventListener(
      'screenReaderChanged',
      setScreenReader,
    );
    const app = AppState.addEventListener('change', (state) =>
      setActive(state === 'active'),
    );
    AccessibilityInfo.announceForAccessibility(toast.message);
    opacity.set(reducedMotion ? 1 : withTiming(1, { duration: 180 }));
    return () => {
      mounted = false;
      reader.remove();
      app.remove();
    };
  }, [opacity, reducedMotion, toast.message]);

  useEffect(() => {
    if (screenReader || focused || dragging || !active) return;
    const timer = setTimeout(dismiss, 6000);
    return () => clearTimeout(timer);
  }, [active, dismiss, dragging, focused, screenReader]);

  const pan = Gesture.Pan()
    .activeOffsetX([-10, 10])
    .failOffsetY([-16, 16])
    .onBegin(() => {
      cancelAnimation(x);
      origin.set(x.get());
      scheduleOnRN(setDragging, true);
    })
    .onUpdate((event) => {
      if (!exiting.get()) x.set(origin.get() + event.translationX);
    })
    .onEnd((event) => {
      if (exiting.get()) return;
      if (Math.abs(x.get()) < 100 && Math.abs(event.velocityX) < 800) {
        x.set(
          reducedMotion ? 0 : withSpring(0, { damping: 20, stiffness: 240 }),
        );
        return;
      }
      exiting.set(true);
      opacity.set(
        withTiming(0, { duration: reducedMotion ? 0 : 160 }, (finished) => {
          if (finished) scheduleOnRN(dismiss);
        }),
      );
    })
    .onFinalize((_event, success) => {
      if (!success && !exiting.get()) x.set(reducedMotion ? 0 : withSpring(0));
      scheduleOnRN(setDragging, false);
    });

  const style = useAnimatedStyle(() => ({
    opacity: opacity.get(),
    transform: [{ translateX: reducedMotion ? 0 : x.get() }],
  }));

  return (
    <View
      pointerEvents="box-none"
      style={[
        styles.overlay,
        { top: topInset + 12, left: leftInset + 16, right: rightInset + 16 },
      ]}
    >
      <GestureDetector gesture={pan}>
        <Animated.View style={[styles.toast, style]}>
          <Text style={styles.message}>{toast.message}</Text>
          <Pressable
            accessibilityRole="button"
            accessibilityLabel="Dismiss notification"
            onFocus={() => setFocused(true)}
            onBlur={() => setFocused(false)}
            onPress={dismiss}
            style={styles.dismiss}
          >
            <Text style={styles.dismissText}>Dismiss</Text>
          </Pressable>
        </Animated.View>
      </GestureDetector>
    </View>
  );
}

export function ToastExample({
  topInset,
  leftInset = 0,
  rightInset = 0,
}: {
  topInset: number;
  leftInset?: number;
  rightInset?: number;
}) {
  const [queue, setQueue] = useState<Toast[]>([]);
  const nextId = useRef(0);
  const dismiss = useCallback((id: number) => {
    setQueue((items) => items.filter((item) => item.id !== id));
  }, []);
  function notify(message: string) {
    const item = { id: ++nextId.current, message };
    setQueue((items) => (items.length >= 5 ? items : [...items, item]));
  }
  const current = queue[0];
  return (
    <GestureHandlerRootView style={styles.screen}>
      <Pressable
        accessibilityRole="button"
        onPress={() => notify('Your draft was saved.')}
        style={styles.trigger}
      >
        <Text>Save draft</Text>
      </Pressable>
      {current ? (
        <ToastHost
          key={current.id}
          toast={current}
          topInset={topInset}
          leftInset={leftInset}
          rightInset={rightInset}
          onDismiss={dismiss}
        />
      ) : null}
    </GestureHandlerRootView>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, justifyContent: 'center', padding: 24 },
  trigger: { padding: 16, backgroundColor: '#EEEAFB', borderRadius: 16 },
  overlay: { position: 'absolute', left: 16, right: 16 },
  toast: {
    backgroundColor: '#262233',
    borderRadius: 18,
    padding: 16,
    flexDirection: 'row',
    alignItems: 'center',
    gap: 12,
  },
  message: { color: '#FFFFFF', flex: 1, fontSize: 16 },
  dismiss: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 8 },
  dismissText: { color: '#E5DBFF' },
});

Queue ownership and dismissal

notify creates the ID once on the React Native runtime, then uses a functional state update to append. That update always sees the latest queue even when several events arrive together. When the queue is full, the new message is dropped; it does not evict the active message. Choose a different overflow policy deliberately if your product needs one. Important events should also have a persistent place in the interface.

The host is keyed by notification ID. A new active item therefore gets fresh opacity, gesture state, and timeout subscriptions. Dismissal filters by ID rather than shifting the array blindly. This matters when a timer and a tap arrive close together: repeated removal of the same ID leaves the next notification intact.

The six-second timeout is a presentation choice, not a delivery guarantee. It restarts with a full reading period after interaction ends or the app returns. The implementation disables automatic dismissal for screen-reader users. There is no per-frame JavaScript timer; one timeout controls lifetime and is cleared whenever its conditions change or the host unmounts.

Swipe dismissal on the UI thread

Horizontal movement must pass ten points before recognition, while a vertical movement beyond sixteen points fails the gesture. These starting thresholds let an ordinary vertical scroll remain usable. The pan cancels any return spring and captures its current position before applying translation, so a second swipe can interrupt the first return.

Release dismisses after 100 points of displacement or a velocity of 800 points per second. Otherwise the same horizontal value springs to zero. The exit animation fades the card and calls scheduleOnRN(dismiss) only after completion. exiting prevents repeated worklet exits. See the official Pan gesture reference and scheduleOnRN documentation.

The visible Dismiss button removes the item immediately. It gives keyboard and assistive-technology users the same outcome without requiring a swipe. If you add an action such as Undo, put its business operation on the React Native runtime and associate it with the item ID; an animation finishing should never repeat a transaction.

Safe-area placement

Pass topInset, leftInset, and rightInset from the screen's current safe-area measurements. In an application using react-native-safe-area-context, wrap the application with SafeAreaProvider, read useSafeAreaInsets() in the screen, and pass its top, left, and right fields to ToastExample. Install that adapter with npx expo install react-native-safe-area-context if needed; the host itself takes numbers so it can reuse an existing safe-area provider.

Follow the SDK 57 safe-area reference for provider placement, including web. Do not pass zero on a device just because it looks correct in a desktop browser. The host adds twelve points below the top inset and sixteen inside each side inset, preserving space around notches in landscape. If its parent already starts inside the safe area, pass offsets relative to that parent to avoid counting them twice.

Mount the host at screen level, above ordinary content. A host inside a clipped card cannot escape that card, and a sibling screen host cannot draw above a native modal. A modal that needs feedback should have its own host within the same presentation surface. For bottom placement, account for both the bottom inset and keyboard overlap instead of simply changing top to bottom.

React Native toast on iOS and Android

A custom view gives iOS and Android the same queue, layout, and dismissal controls. React Native's Android-specific toast API is a different presentation mechanism; it does not supply this cross-platform interactive host. Verify the custom view with both platform screen readers and with your real navigation hierarchy.

The sample announces the message once through AccessibilityInfo. It intentionally avoids adding a second live region to the same message. On web, test announcement behavior with the browsers you support; a web-specific status region may be appropriate instead of the native announcement adapter. Never combine multiple announcement paths without checking for duplicate speech.

Accessibility and reduced motion

The message remains visible text, and the dismiss control has a full label and a minimum forty-four-point height. Increase the layout's available width or let it grow vertically for longer messages and larger text settings. Avoid truncating essential feedback to keep a compact aesthetic.

With reduced motion, opacity enters immediately and the card stays in place while the swipe is recognized. The logical dismissal threshold still works, but the notification does not travel across the screen. Cancellation also returns directly. Preserve the message and controls when motion is reduced; the animation is optional, the confirmation is useful.

Performance and verification

Only one host is mounted. Queue updates happen on enqueue and dismissal, while translation and opacity run in animated styles. Accessibility and app-state listeners are removed on unmount, and the initial screen-reader promise ignores results after unmount. Keep message formatting and networking outside gesture callbacks.

Test a burst of more than five notifications, repeated dismissal, a canceled gesture, slow drags below threshold, fast flicks, backgrounding, large text, rotation, and a screen reader enabled before opening the screen. Check that tapping content outside the notification still works: the overlay uses pointerEvents="box-none". Measure on devices before making any frame-rate claim.

Related patterns

For compact unread counts, the published Bump Badge animates a notification counter; it is not a toast queue. For local action confirmation, Copy Confirm Button keeps feedback at the source of the action. For a finished version, the free Toast Stack fans a notification stack open with depth and dismisses each toast with a physical swipe; its source is also in the public repository.

The skeleton loader guide handles pending content, and the progress bar guide handles measurable completion. Choose these patterns according to the state you need to explain rather than using a transient message for everything.

FAQ

How do I show a toast notification in React Native on iOS?

Render a custom host in your screen hierarchy, provide current safe-area offsets, and drive its presentation with Reanimated. The implementation above also works as the same interaction model on Android.

How do I queue multiple notifications?

Store items with stable IDs in React state and render only the first. Remove by ID and use a functional update when appending, so concurrent events cannot overwrite the queue.

Should a toast disappear automatically for screen-reader users?

This example leaves it visible until explicitly dismissed when a screen reader is enabled. That avoids requiring users to find and operate a control before a short timer expires.

Can a toast replace form validation?

Use visible field-level errors for validation and recovery. A toast can reinforce a successful action, but users should not have to remember a disappearing message to fix a form.