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.
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 toastToast 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#
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#
// 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 matchposition, so the natural pairing just works.
// 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.
titlestringrequireddescriptionstring—variant"default" | "destructive" | "success""default"position"top" | "bottom"provider default ("top")Where the toast rests on screen.
from"top" | "bottom" | "left" | "right"matches positionWhich edge the toast slides in from. Defaults to match position so the natural pairing just works.
ToastProvider#
childrenReact.ReactNoderequireddefaultPosition"top" | "bottom""top"App-wide default resting position.
defaultFrom"top" | "bottom" | "left" | "right"matches defaultPositionApp-wide default slide-in direction.
Accessibility#
- Uses React Context provider with auto-dismiss timer
accessibilityRole="alert"on each toast for screen reader announcements.
Source#
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>
);
}