AniUI

Toast

Notification toast with slide-in animation and auto-dismiss. Toasts rest at the top or bottom of the screen and can slide in from any of the four sides — position and animation direction are independent.

Web preview — components render natively on iOS & Android
import { ToastProvider, useToast } from "@/components/ui/toast";
// Wrap your app with ToastProvider
export function App() {
  return (
    <ToastProvider>
      <MyScreen />
    </ToastProvider>
  );
}
function MyScreen() {
  const { toast } = useToast();
  return (
    <Button
      onPress={() =>
        toast({ title: "Success!", description: "Your action was completed." })
      }
    >
      Show Toast
    </Button>
  );
}

Installation#

npx @aniui/cli add toast

Toast renders through a Portal so it always anchors to the screen instead of the nearest positioned ancestor (otherwise wrapping ToastProvider inside a ScrollView would make toasts scroll with the content). The CLI adds <PortalHost /> to your root layout automatically. Requires react-native-reanimated for the slide animations and @rn-primitives/portal for the portal host.

Usage#

app/index.tsx
import { ToastProvider, useToast } from "@/components/ui/toast";
// Wrap your app with ToastProvider
export function App() {
  return (
    <ToastProvider>
      <MyScreen />
    </ToastProvider>
  );
}
function MyScreen() {
  const { toast } = useToast();
  return (
    <Button
      onPress={() =>
        toast({ title: "Success!", description: "Your action was completed." })
      }
    >
      Show Toast
    </Button>
  );
}

Variants#

app/index.tsx
// Default toast
toast({ title: "Notification", description: "Something happened." });
// Destructive toast
toast({ title: "Error", description: "Something went wrong.", variant: "destructive" });
// Success toast
toast({ title: "Saved", description: "Changes saved successfully.", variant: "success" });

Position & slide direction#

A toast has two independent controls:

  • position — where the toast rests on screen ("top" or "bottom"). Both span the full width with consistent margins.
  • from — which edge the toast slides in from ("top", "bottom", "left", or "right"). Defaults to match position, so the natural pairing just works.
app/index.tsx
// Two independent concerns:
//   position — where the toast RESTS on screen ("top" | "bottom", default "top")
//   from     — which side it SLIDES IN FROM ("top" | "bottom" | "left" | "right",
//              defaults to match position so the natural pairing just works)

// Default — drops down from above into the top resting position.
toast({ title: "Heads up" });

// Pinned to the bottom, rises in from below.
toast({ title: "Saved", position: "bottom" });

// Pinned to top but flies in from the right edge.
toast({ title: "New message", position: "top", from: "right" });

// App-wide defaults on the provider.
<ToastProvider defaultPosition="bottom" defaultFrom="left">
  <App />
</ToastProvider>

Props#

useToast#

Returns an object with a toast function.

PropTypeDefault
title
string
required
description
string
variant
"default" | "destructive" | "success"
"default"
position
"top" | "bottom"
provider default ("top")

Where the toast rests on screen.

from
"top" | "bottom" | "left" | "right"
matches position

Which edge the toast slides in from. Defaults to match position so the natural pairing just works.

ToastProvider#

PropTypeDefault
children
React.ReactNode
required
defaultPosition
"top" | "bottom"
"top"

App-wide default resting position.

defaultFrom
"top" | "bottom" | "left" | "right"
matches defaultPosition

App-wide default slide-in direction.

Accessibility#

  • Uses React Context provider with auto-dismiss timer
  • accessibilityRole="alert" on each toast for screen reader announcements.

Source#

components/ui/toast.tsx
import React, { createContext, useCallback, useContext, useState } from "react";
import { View, Text, Pressable } from "react-native";
import Animated from "react-native-reanimated";
import { Portal } from "@rn-primitives/portal";
import { entering, exiting } from "@/components/ui/animate";
import { cn } from "@/lib/utils";

type ToastVariant = "default" | "destructive" | "success";
export type ToastPosition = "top" | "bottom";
export type ToastFrom = "top" | "bottom" | "left" | "right";
type ToastData = {
  id: string; title: string; description?: string;
  variant?: ToastVariant; position?: ToastPosition; from?: ToastFrom;
};

const ToastContext = createContext<{ toast: (data: Omit<ToastData, "id">) => void }>({ toast: () => {} });
export function useToast() { return useContext(ToastContext); }

export interface ToastProviderProps {
  children: React.ReactNode;
  defaultPosition?: ToastPosition;
  defaultFrom?: ToastFrom;
}

const animationFor: Record<ToastFrom, { enter: typeof entering.slideInUp; exit: typeof exiting.slideOutUp }> = {
  top:    { enter: entering.slideInUp,    exit: exiting.slideOutUp },
  bottom: { enter: entering.slideInDown,  exit: exiting.slideOutDown },
  left:   { enter: entering.slideInLeft,  exit: exiting.slideOutLeft },
  right:  { enter: entering.slideInRight, exit: exiting.slideOutRight },
};

const containerStyles: Record<ToastPosition, string> = {
  top:    "absolute top-14 start-4 end-4 gap-2 z-50",
  bottom: "absolute bottom-14 start-4 end-4 gap-2 z-50",
};

const positions: ToastPosition[] = ["top", "bottom"];

export function ToastProvider({ children, defaultPosition = "top", defaultFrom }: ToastProviderProps) {
  const [toasts, setToasts] = useState<ToastData[]>([]);
  const toast = useCallback((data: Omit<ToastData, "id">) => {
    const id = Date.now().toString();
    const position = data.position ?? defaultPosition;
    const from = data.from ?? defaultFrom ?? position;
    setToasts((prev) => [...prev, { ...data, id, position, from }]);
    setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000);
  }, [defaultPosition, defaultFrom]);
  const dismiss = (id: string) => setToasts((prev) => prev.filter((t) => t.id !== id));

  return (
    <ToastContext.Provider value={{ toast }}>
      {children}
      <Portal name="aniui-toast">
        {positions.map((pos) => {
          const items = toasts.filter((t) => t.position === pos);
          if (items.length === 0) return null;
          return (
            <View key={pos} className={containerStyles[pos]} pointerEvents="box-none">
              {items.map((t) => (
                <ToastItem key={t.id} data={t} onDismiss={() => dismiss(t.id)} />
              ))}
            </View>
          );
        })}
      </Portal>
    </ToastContext.Provider>
  );
}

const variantStyles: Record<ToastVariant, string> = {
  default: "bg-card border-border",
  destructive: "bg-destructive border-destructive",
  success: "bg-green-600 border-green-600",
};

function ToastItem({ data, onDismiss }: { data: ToastData; onDismiss: () => void }) {
  const variant = data.variant ?? "default";
  const isDefault = variant === "default";
  const { enter, exit } = animationFor[data.from ?? "top"];
  return (
    <Animated.View entering={enter} exiting={exit}>
      <Pressable
        className={cn("rounded-lg border p-4 shadow-lg", variantStyles[variant])}
        onPress={onDismiss}
        accessible={true}
        accessibilityRole="alert"
      >
        <Text className={cn("text-sm font-semibold", isDefault ? "text-foreground" : "text-white")}>{data.title}</Text>
        {data.description && (
          <Text className={cn("text-xs mt-1", isDefault ? "text-muted-foreground" : "text-white/80")}>{data.description}</Text>
        )}
      </Pressable>
    </Animated.View>
  );
}