Skip to content

Instantly share code, notes, and snippets.

@nghiatv
Created August 9, 2025 18:20
Show Gist options
  • Save nghiatv/322c0dc5cad6b95eccc66c8b6da8645c to your computer and use it in GitHub Desktop.
Save nghiatv/322c0dc5cad6b95eccc66c8b6da8645c to your computer and use it in GitHub Desktop.
import { Text } from "@/components/ui/text";
import { AlertCircle, Check, Info, X } from "lucide-react-native";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
Dimensions,
Platform,
TouchableOpacity,
View,
ViewStyle,
} from "react-native";
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from "react-native-gesture-handler";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
runOnJS,
} from "react-native-reanimated";
export type ToastVariant = "default" | "success" | "error" | "warning" | "info";
export interface ToastData {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
duration?: number;
action?: {
label: string;
onPress: () => void;
};
}
interface ToastProps extends ToastData {
onDismiss: (id: string) => void;
index: number;
}
const { width: screenWidth } = Dimensions.get("window");
const DYNAMIC_ISLAND_HEIGHT = 37;
const EXPANDED_HEIGHT = 85;
const TOAST_MARGIN = 8;
const DYNAMIC_ISLAND_WIDTH = 126;
const EXPANDED_WIDTH = screenWidth - 32;
export function Toast({
id,
title,
description,
variant = "default",
onDismiss,
index,
action,
}: ToastProps) {
const [isExpanded, setIsExpanded] = useState(false);
const [hasContent, setHasContent] = useState(false);
// Convert Animated.Value to SharedValue
const translateY = useSharedValue(-100);
const translateX = useSharedValue(0);
const opacity = useSharedValue(0);
const scale = useSharedValue(0.8);
const width = useSharedValue(DYNAMIC_ISLAND_WIDTH);
const height = useSharedValue(DYNAMIC_ISLAND_HEIGHT);
const borderRadius = useSharedValue(18.5);
const contentOpacity = useSharedValue(0);
// Dynamic Island colors (dark theme optimized)
const backgroundColor = "#1C1C1E"; // iOS Dynamic Island background
const mutedTextColor = "#8E8E93"; // iOS secondary text color
useEffect(() => {
const hasContentToShow = Boolean(title || description || action);
setHasContent(hasContentToShow);
if (hasContentToShow) {
// If there's content, start directly with expanded state
width.value = EXPANDED_WIDTH;
height.value = EXPANDED_HEIGHT;
borderRadius.value = 20;
setIsExpanded(true);
// Single smooth animation for expanded toast
translateY.value = withSpring(0, {
stiffness: 120,
damping: 8,
});
opacity.value = withTiming(1, {
duration: 300,
});
scale.value = withSpring(1, {
stiffness: 120,
damping: 8,
});
contentOpacity.value = withTiming(1, {
duration: 300,
});
// Add delay using setTimeout
setTimeout(() => {
contentOpacity.value = withTiming(1, {
duration: 300,
});
}, 100);
} else {
// If no content, show compact Dynamic Island with icon only
setIsExpanded(false);
translateY.value = withSpring(0, {
stiffness: 120,
damping: 8,
});
opacity.value = withTiming(1, {
duration: 200,
});
scale.value = withSpring(1, {
stiffness: 120,
damping: 8,
});
}
return () => {
translateY.value = -100;
opacity.value = 0;
scale.value = 0.8;
};
}, [title, description, action]); // Added dependencies to handle content changes
const getVariantColor = () => {
switch (variant) {
case "success":
return "#30D158"; // iOS green
case "error":
return "#FF453A"; // iOS red
case "warning":
return "#FF9F0A"; // iOS orange
case "info":
return "#007AFF"; // iOS blue
default:
return "#8E8E93"; // iOS gray
}
};
const getIcon = () => {
const iconProps = { size: 16, color: getVariantColor() };
switch (variant) {
case "success":
return <Check {...iconProps} />;
case "error":
return <X {...iconProps} />;
case "warning":
return <AlertCircle {...iconProps} />;
case "info":
return <Info {...iconProps} />;
default:
return null;
}
};
const dismiss = () => {
translateY.value = withSpring(-100, {
stiffness: 120,
damping: 8,
});
opacity.value = withTiming(0, {
duration: 250,
});
scale.value = withSpring(0.8, {
stiffness: 120,
damping: 8,
});
// Defer the state update to avoid useInsertionEffect timing issues
setTimeout(() => onDismiss(id), 300);
};
const panGesture = Gesture.Pan()
.runOnJS(true)
.activeOffsetX([-10, 10]) // Only activate if vertical movement is beyond this threshold
.activeOffsetY([-Infinity, Infinity]) // Allow any horizontal movement without activating
.onUpdate((event) => {
translateX.value = event.translationX;
})
.onEnd((event) => {
const { translationX, velocityX } = event;
if (
Math.abs(translationX) > screenWidth * 0.25 ||
Math.abs(velocityX) > 800
) {
// Dismiss the toast
translateX.value = withTiming(
translationX > 0 ? screenWidth : -screenWidth,
{
duration: 250,
}
);
opacity.value = withTiming(0, {
duration: 250,
});
// Defer the state update to avoid useInsertionEffect timing issues
setTimeout(() => {
runOnJS(onDismiss)(id);
}, 250);
} else {
// Snap back with spring animation
translateX.value = withSpring(0, {
stiffness: 120,
damping: 8,
});
}
});
const getTopPosition = () => {
const statusBarHeight = Platform.OS === "ios" ? 59 : 20;
return statusBarHeight + index * (EXPANDED_HEIGHT + TOAST_MARGIN);
};
// Create animated styles using useAnimatedStyle
const animatedStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateY: translateY.value },
{ translateX: translateX.value },
{ scale: scale.value },
],
opacity: opacity.value,
};
});
const containerAnimatedStyle = useAnimatedStyle(() => {
return {
width: width.value,
height: height.value,
borderRadius: borderRadius.value,
};
});
const contentAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: contentOpacity.value,
};
});
const toastStyle: ViewStyle = {
position: "absolute",
top: getTopPosition(),
alignSelf: "center",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 8,
},
shadowOpacity: 0.25,
shadowRadius: 20,
elevation: 10,
zIndex: 1000 + index,
};
const dynamicIslandStyle = {
backgroundColor,
justifyContent: "center" as const,
alignItems: "center" as const,
overflow: "hidden" as const,
};
return (
<GestureDetector gesture={panGesture}>
<Animated.View style={[toastStyle, animatedStyle]}>
<Animated.View style={[dynamicIslandStyle, containerAnimatedStyle]}>
{/* Compact state - just icon or indicator */}
{!isExpanded && (
<View style={{ justifyContent: "center", alignItems: "center" }}>
{getIcon()}
</View>
)}
{/* Expanded state - full content */}
{isExpanded && (
<Animated.View
style={[
{
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: 0,
paddingHorizontal: 16,
paddingVertical: 12,
flexDirection: "row",
alignItems: "center",
},
contentAnimatedStyle,
]}
>
{getIcon() && (
<View style={{ marginRight: 12 }}>{getIcon()}</View>
)}
<View style={{ flex: 1, minWidth: 0 }}>
{title && (
<Text
variant="subtitle"
style={{
color: "#FFFFFF",
fontSize: 15,
fontWeight: "600",
marginBottom: description ? 2 : 0,
}}
numberOfLines={1}
ellipsizeMode="tail"
>
{title}
</Text>
)}
{description && (
<Text
variant="caption"
style={{
color: mutedTextColor,
fontSize: 13,
fontWeight: "400",
}}
numberOfLines={2}
ellipsizeMode="tail"
>
{description}
</Text>
)}
</View>
{action && (
<TouchableOpacity
onPress={action.onPress}
style={{
marginLeft: 12,
paddingHorizontal: 12,
paddingVertical: 6,
backgroundColor: getVariantColor(),
borderRadius: 12,
}}
>
<Text
variant="caption"
style={{
color: "#FFFFFF",
fontSize: 12,
fontWeight: "600",
}}
>
{action.label}
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
onPress={dismiss}
style={{
marginLeft: 8,
padding: 4,
borderRadius: 8,
}}
>
<X size={14} color={mutedTextColor} />
</TouchableOpacity>
</Animated.View>
)}
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
interface ToastContextType {
toast: (toast: Omit<ToastData, "id">) => void;
success: (title: string, description?: string) => void;
error: (title: string, description?: string) => void;
warning: (title: string, description?: string) => void;
info: (title: string, description?: string) => void;
dismiss: (id: string) => void;
dismissAll: () => void;
}
const ToastContext = createContext<ToastContextType | null>(null);
interface ToastProviderProps {
children: React.ReactNode;
maxToasts?: number;
}
export function ToastProvider({ children, maxToasts = 3 }: ToastProviderProps) {
const [toasts, setToasts] = useState<ToastData[]>([]);
const generateId = () => Math.random().toString(36).substr(2, 9);
const addToast = useCallback(
(toastData: Omit<ToastData, "id">) => {
const id = generateId();
const newToast: ToastData = {
...toastData,
id,
duration: toastData.duration ?? 4000,
};
setToasts((prev) => {
const updated = [newToast, ...prev];
return updated.slice(0, maxToasts);
});
// Auto dismiss after duration
if (newToast.duration && newToast.duration > 0) {
setTimeout(() => {
dismissToast(id);
}, newToast.duration);
}
},
[maxToasts]
);
const dismissToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const dismissAll = useCallback(() => {
setToasts([]);
}, []);
const createVariantToast = useCallback(
(variant: ToastVariant, title: string, description?: string) => {
addToast({
title,
description,
variant,
});
},
[addToast]
);
const contextValue: ToastContextType = {
toast: addToast,
success: (title, description) =>
createVariantToast("success", title, description),
error: (title, description) =>
createVariantToast("error", title, description),
warning: (title, description) =>
createVariantToast("warning", title, description),
info: (title, description) =>
createVariantToast("info", title, description),
dismiss: dismissToast,
dismissAll,
};
const containerStyle: ViewStyle = {
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 1000,
pointerEvents: "box-none",
};
return (
<ToastContext.Provider value={contextValue}>
<GestureHandlerRootView style={{ flex: 1 }}>
{children}
<View style={containerStyle} pointerEvents="box-none">
{toasts.map((toast, index) => (
<Toast
key={toast.id}
{...toast}
index={index}
onDismiss={dismissToast}
/>
))}
</View>
</GestureHandlerRootView>
</ToastContext.Provider>
);
}
// Hook to use toast
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment