/** * Toast — transient notification. * * Three ways to raise one, because the right amount of ceremony differs: a * bare string, a config object with a title, description and action, or a * custom component when none of that fits. The anatomy underneath is the same * in all three. * * The viewport is mounted for you by PanelUIProvider; `useToast()` works * anywhere below it. */ import { createContext, useCallback, useContext, useEffect, useMemo, useSyncExternalStore, type ReactNode, } from 'react'; import { AppState, View, type AppStateStatus, type ViewProps, } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { Easing, Extrapolation, FadeIn, FadeInDown, FadeInUp, FadeOut, Keyframe, interpolate, runOnJS, useAnimatedStyle, useDerivedValue, useReducedMotion, useSharedValue, withDecay, withSpring, withTiming, type SharedValue, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { tv, type VariantProps } from 'tailwind-variants'; import { useCSSVariable } from 'uniwind'; import { Portal } from '../../primitives/portal'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { AlertTriangleIcon, CheckCircleIcon, InfoIcon, XIcon } from '../../icons'; import { Button, type ButtonProps } from '../button'; import { toast, toastStore, type ToastHandle, type ToastItem, type ToastPlacement, type ToastVariant, } from './toast-store'; const SPRING = { damping: 20, stiffness: 260, mass: 0.6 } as const; /** * The deck reshuffling after a dismissal. Slower and softer than the drag's * spring: the cards behind are not what the reader is acting on, and a stack * that snaps draws the eye to the wrong thing. */ const STACK_SPRING = { duration: 350, dampingRatio: 0.9 } as const; /** Drag distance past which a release dismisses. */ const DISMISS_DISTANCE = 50; /** Fling speed past which a release dismisses regardless of distance. */ const DISMISS_VELOCITY = 500; /** How far a toast can be dragged away from its edge before it stops moving. */ const RUBBER_BAND_LIMIT = 40; /** How far a toast travels on entry. Short, so the spring has little to settle. */ const ENTER_OFFSET = 100; /** Newest toasts only; older ones fade out rather than stacking off-screen. */ const MAX_VISIBLE = 3; /** How far each toast behind the front one peeks out. */ const STACK_OFFSET = 10; /** Scale of the toast one step back in the deck. */ const STACK_SCALE_STEP = 0.97; /** Gap between the deck and the screen edges, on top of the safe-area inset. */ const EDGE_INSET = 16; /* * Entering and exiting animations. * * `mass(3)` is doing the important work: a heavy spring settles without the * visible overshoot a default-mass one gives. `withInitialValues` keeps * opacity at 1 so the toast slides rather than fades, and starts it * ENTER_OFFSET px away instead of off-screen. */ const enteringTop = FadeInUp.springify() .withInitialValues({ opacity: 1, transform: [{ translateY: -ENTER_OFFSET }] }) .mass(3); const enteringBottom = FadeInDown.springify() .withInitialValues({ opacity: 1, transform: [{ translateY: ENTER_OFFSET }] }) .mass(3); /* * It leaves the way it arrived, a little quicker. * * The curve was an ease-*in*, which starts slow — it spends the first frames of * the exit barely moving, which is exactly the moment the reader is looking at * the thing that just changed. This is the ease-out used everywhere else in the * library, and the duration is a fifth under the entrance: the arrival earns * the time, the departure does not. */ const exitKeyframe = (offset: number) => new Keyframe({ 0: { opacity: 1, transform: [{ translateY: 0 }, { scale: 1 }] }, 100: { opacity: 0, transform: [{ translateY: offset }, { scale: 0.97 }], easing: Easing.bezier(0.23, 1, 0.32, 1), }, }).duration(240); const exitingTop = exitKeyframe(-ENTER_OFFSET); const exitingBottom = exitKeyframe(ENTER_OFFSET); const toastVariants = tv({ slots: { root: 'w-full flex-row items-start gap-3 rounded-2xl border px-4 py-3.5 shadow-lg', indicator: 'pt-px', content: 'flex-1 gap-0.5', title: 'text-sm font-medium', description: 'text-sm text-muted-foreground', }, variants: { variant: { default: { root: 'border-border bg-popover', title: 'text-popover-foreground' }, info: { root: 'border-info/32 bg-popover', title: 'text-info-foreground' }, success: { root: 'border-success/32 bg-popover', title: 'text-success-foreground' }, warning: { root: 'border-warning/32 bg-popover', title: 'text-warning-foreground' }, destructive: { root: 'border-destructive/32 bg-popover', title: 'text-destructive-foreground', }, }, }, defaultVariants: { variant: 'default', }, }); const INDICATOR_COLOR_VAR: Record = { default: '--color-muted-foreground', info: '--color-info-foreground', success: '--color-success-foreground', warning: '--color-warning-foreground', destructive: '--color-destructive-foreground', }; const INDICATOR_ICON: Record = { default: InfoIcon, info: InfoIcon, success: CheckCircleIcon, warning: AlertTriangleIcon, destructive: AlertTriangleIcon, }; interface ToastContextValue { variant: ToastVariant; hide: () => void; } const ToastContext = createContext({ variant: 'default', hide: () => {}, }); export interface ToastProps extends ViewProps, VariantProps { className?: string; /** Called when the close button is pressed or the toast is swiped away. */ onHide?: () => void; children?: ReactNode; } /** * Presentational toast surface. Render it yourself only for custom toasts — * `toast.show(...)` builds one for you from a config object. */ const ToastRoot = ({ className, variant = 'default', onHide, children, ...props }: ToastProps) => { const context = useMemo( () => ({ variant, hide: onHide ?? (() => {}) }), [variant, onHide] ); const { root } = toastVariants({ variant }); return ( {textChildren(children)} ); }; ToastRoot.displayName = 'Toast'; export interface ToastIndicatorProps extends ViewProps { className?: string; iconProps?: { size?: number; color?: string }; children?: ReactNode; } const ToastIndicator = ({ className, iconProps, children, ...props }: ToastIndicatorProps) => { const { variant } = useContext(ToastContext); const { indicator } = toastVariants({ variant }); const themeColor = useCSSVariable(INDICATOR_COLOR_VAR[variant]); const Icon = INDICATOR_ICON[variant]; const color = iconProps?.color ?? (typeof themeColor === 'string' ? themeColor : undefined); return ( {children ?? } ); }; ToastIndicator.displayName = 'Toast.Indicator'; const ToastContent = ({ className, ...props }: ViewProps & { className?: string }) => { const { variant } = useContext(ToastContext); const { content } = toastVariants({ variant }); return ; }; ToastContent.displayName = 'Toast.Content'; const ToastTitle = ({ className, ...props }: TextProps) => { const { variant } = useContext(ToastContext); const { title } = toastVariants({ variant }); return ; }; ToastTitle.displayName = 'Toast.Title'; const ToastDescription = ({ className, ...props }: TextProps) => { const { variant } = useContext(ToastContext); const { description } = toastVariants({ variant }); return ; }; ToastDescription.displayName = 'Toast.Description'; /** Trailing action button. Defaults to a subtle variant that reads on the popover surface. */ const ToastAction = ({ variant = 'secondary', size = 'sm', onPress, ...props }: ButtonProps) => { const { hide } = useContext(ToastContext); return ( ); }; ToastClose.displayName = 'Toast.Close'; export const Toast = Object.assign(ToastRoot, { Indicator: ToastIndicator, Content: ToastContent, Title: ToastTitle, Description: ToastDescription, Action: ToastAction, Close: ToastClose, }); /* -------------------------------------------------------------------------- */ /* Viewport */ /* -------------------------------------------------------------------------- */ /** * One queued toast. * * The entering spring is deliberately heavy (`mass(3)`) and travels only * ENTER_OFFSET px rather than the full screen height. A default-mass spring * across that distance overshoots visibly — it reads as a bounce, and arrives * too fast to follow. * * Swiping is vertical and direction-aware: dragging toward the screen edge the * toast came from dismisses it, dragging the other way rubber-bands against a * hard limit. */ function ToastSlot({ item, index, total, frontId, heights, }: { item: ToastItem; /** Position in the stack; the newest toast is `total - 1`. */ index: number; total: number; /** Id of the newest toast — its height sets the size of the whole deck. */ frontId: string; /** Measured natural height per toast id, shared with the UI thread. */ heights: SharedValue>; }) { const placement = item.placement ?? 'bottom'; const translateY = useSharedValue(0); const scale = useSharedValue(1); const reducedMotion = useReducedMotion(); const hide = useCallback(() => toastStore.hide(item.id), [item.id]); // Drop this toast's measurement when it leaves, so the map cannot grow // without bound over a long session. useEffect( () => () => { heights.modify((value: Record) => { 'worklet'; const next = { ...value }; delete next[item.id]; return next; }); }, [heights, item.id] ); /** +1 when a downward drag dismisses (bottom), -1 when an upward one does. */ const dismissSign = placement === 'top' ? -1 : 1; const pan = Gesture.Pan() .activeOffsetY([-12, 12]) .onBegin(() => { scale.value = withTiming(0.995, { duration: 120 }); }) .onChange((event) => { const next = translateY.value + event.changeY; // Toward the edge: follow the finger. Away from it: rubber-band, so the // toast never detaches from where it belongs. translateY.value = next * dismissSign > 0 ? next : interpolate( Math.abs(next), [0, 400], [0, RUBBER_BAND_LIMIT], Extrapolation.CLAMP ) * -dismissSign; }) .onFinalize((event) => { scale.value = withTiming(1, { duration: 120 }); const towardEdge = translateY.value * dismissSign > 0; const shouldDismiss = towardEdge && (Math.abs(translateY.value) > DISMISS_DISTANCE || Math.abs(event.velocityY) > DISMISS_VELOCITY); if (shouldDismiss) { // Carry the flick's momentum instead of stopping dead, clamped so it // can only continue toward the edge. translateY.value = withDecay({ velocity: event.velocityY * 1.5, clamp: dismissSign > 0 ? [0, Number.POSITIVE_INFINITY] : [Number.NEGATIVE_INFINITY, 0], }); runOnJS(hide)(); } else { translateY.value = withSpring(0, SPRING); } }); /** Older toasts slide away from the edge the stack is pinned to. */ const stackSign = placement === 'top' ? 1 : -1; /** * Stack position plus the gesture transform. * * The whole deck takes the *newest* toast's height so the cards behind read * as a uniform stack rather than a ragged pile. */ /* * How far back in the deck this toast is: 0 is the front one. * * Sprung rather than read straight off the props. Position used to be * computed from `index` and `total` directly, so dismissing a toast in the * middle teleported every card behind it forward a step between two frames. * The deck reshuffling is the one moment the stack is worth watching, and it * was the one moment it did not move. */ const depth = useDerivedValue(() => reducedMotion ? total - 1 - index : withSpring(total - 1 - index, STACK_SPRING) ); const stackStyle = useAnimatedStyle(() => { // Fall back to this toast's own height until the front one is measured. const frontHeight = heights.value[frontId] ?? heights.value[item.id]; // Front sits at 0; each one behind steps back by STACK_OFFSET and shrinks. const back = depth.value; const offset = interpolate(back, [0, 1], [0, STACK_OFFSET * stackSign], { extrapolateLeft: Extrapolation.CLAMP, }); const stackScale = interpolate(back, [0, 1], [1, STACK_SCALE_STEP], { extrapolateLeft: Extrapolation.CLAMP, }); // Anything past MAX_VISIBLE fades out rather than piling up forever. const opacity = interpolate( back, [MAX_VISIBLE - 1, MAX_VISIBLE], [1, 0], Extrapolation.CLAMP ); return { opacity, height: frontHeight, transform: [ { translateY: offset + translateY.value }, { scale: stackScale * scale.value }, ], }; }); const handle: ToastHandle = { id: item.id, hide }; const content = item.component ? ( item.component(handle) ) : ( {item.icon === null ? null : ( {item.icon ?? undefined} )} {item.label ? {item.label} : null} {item.description ? ( {item.description} ) : null} {item.actionLabel ? ( item.onActionPress?.(handle)}> {item.actionLabel} ) : null} {(item.closable ?? !item.actionLabel) ? : null} ); return ( {content} {/* A hidden copy, purely to measure. The visible one above has its height forced by the animated style, so it can never report its natural size. */} { const measured = event.nativeEvent.layout.height; heights.modify((value: Record) => { 'worklet'; if (value[item.id] === measured) return value; return { ...value, [item.id]: measured }; }); }} > {content} ); } /** * The deck for one edge of the screen. * * Toasts are not laid out in flow — each one is absolutely pinned to the same * edge and offset by its stack position, so they overlap like a deck of cards * with only the newest fully visible. */ function ToastStack({ items, placement, }: { items: readonly ToastItem[]; placement: ToastPlacement; }) { const insets = useSafeAreaInsets(); // Natural heights, keyed by toast id. Written from the UI thread by each // slot's hidden measuring copy. const heights = useSharedValue>({}); if (items.length === 0) return null; // Keep a couple beyond MAX_VISIBLE mounted so the ones fading out still // animate rather than popping. const visible = items.slice(-(MAX_VISIBLE + 1)); const frontId = visible[visible.length - 1]!.id; return ( // A zero-height anchor sitting at the safe-area edge. The toasts hang off // it — upward for bottom placement, downward for top — so the inset is // part of the anchor's position rather than padding, which Yoga would // ignore for absolutely positioned children. {visible.map((item, index) => ( ))} ); } /** * Renders the toast queue. PanelUIProvider mounts this automatically — you * only need it directly if you build your own provider. */ export function ToastViewport() { const items = useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot); useEffect(() => { /* * Pause on the two states that mean nobody can see the toast, rather than * on everything that is not `active`. Android reports `unknown` before it * has decided, and a toast raised in that window would otherwise have its * countdown parked with nothing coming to restart it. */ const syncTimers = (state: AppStateStatus) => { if (state === 'background' || state === 'inactive') toastStore.pauseTimers(); else toastStore.resumeTimers(); }; syncTimers(AppState.currentState); const subscription = AppState.addEventListener('change', syncTimers); return () => { subscription.remove(); toastStore.pauseTimers(); }; }, []); if (items.length === 0) return null; const top = items.filter((item) => item.placement === 'top'); const bottom = items.filter((item) => item.placement !== 'top'); return ( ); } /** * Access the toast API. * * ```tsx * const { toast } = useToast(); * toast.show({ variant: 'success', label: 'Saved' }); * ``` */ export function useToast() { return { toast }; } export { toast, type ToastItem, type ToastPlacement, type ToastVariant, type ToastHandle }; export type { ToastOptions } from './toast-store';