Toast Stack
Notifications that stack like cards, fan out on tap and swipe away sideways.
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import {
AccessibilityInfo,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic, type HapticKind } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type ToastTone = 'neutral' | 'success' | 'warning' | 'danger';
export type ToastOptions = {
title: string;
description?: string;
/** Defaults to `neutral`. */
tone?: ToastTone;
/** Auto-dismiss after this many ms. `null` keeps it until dismissed. Defaults to the provider's duration. */
duration?: number | null;
/** One optional action, e.g. Undo. Pressing it dismisses the toast. */
action?: { label: string; onPress: () => void };
};
type ToastRecord = ToastOptions & { id: number; leaving: boolean };
type ToastApi = {
/** Shows a toast and returns its id. */
show: (options: ToastOptions) => number;
dismiss: (id: number) => void;
dismissAll: () => void;
};
const ToastContext = createContext<ToastApi | null>(null);
/** Shows and dismisses toasts. Must be used inside a `ToastProvider`. */
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) throw new Error('useToast must be used inside <ToastProvider>');
return api;
}
export type ToastProviderProps = {
children: ReactNode;
/** Defaults to `top`. */
placement?: 'top' | 'bottom';
/** Default auto-dismiss time in ms. Defaults to 4000. */
duration?: number;
/** Toasts kept in the stack; older ones are dropped. Defaults to 3. */
max?: number;
/** Distance from the safe-area edge, in pt. Defaults to 8. */
offset?: number;
/** Plays a haptic for success, warning and danger toasts. Defaults to true. */
haptics?: boolean;
/** Keep clear of the status bar and home indicator. Turn off when the provider wraps a small region rather than the screen. Defaults to true. */
safeArea?: boolean;
};
const PEEK = 10;
const GAP = 8;
const toneHaptic: Record<ToastTone, HapticKind | null> = {
neutral: null,
success: 'success',
warning: 'warning',
danger: 'error',
};
/**
* Hosts a stack of toasts. New toasts slide in on top of the stack and older
* ones tuck behind. Tap the stack to fan it out; swipe a toast sideways to
* dismiss it. Timers pause while the stack is open.
*/
export function ToastProvider({
children,
placement = 'top',
duration = 4000,
max = 3,
offset = 8,
haptics = true,
safeArea = true,
}: ToastProviderProps) {
const haptic = useHaptic(haptics);
const insets = useSafeAreaInsets();
const { space } = useKinetikTheme();
const [toasts, setToasts] = useState<ToastRecord[]>([]);
const [expanded, setExpanded] = useState(false);
const [heights, setHeights] = useState<Record<number, number>>({});
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const remove = useCallback((id: number) => {
setToasts((all) => all.filter((t) => t.id !== id));
setHeights(({ [id]: _, ...rest }) => rest);
}, []);
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
timers.current.delete(id);
setToasts((all) => all.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
}, []);
const dismissAll = useCallback(() => {
setExpanded(false);
timers.current.forEach(clearTimeout);
timers.current.clear();
setToasts((all) => all.map((t) => ({ ...t, leaving: true })));
}, []);
const show = useCallback(
(options: ToastOptions) => {
const id = nextId.current++;
setToasts((all) => {
const next = [{ ...options, id, leaving: false }, ...all];
// Drop the oldest beyond `max`, letting them animate out.
return next.map((t, i) => (i >= max && !t.leaving ? { ...t, leaving: true } : t));
});
const kind = toneHaptic[options.tone ?? 'neutral'];
if (kind) haptic(kind);
AccessibilityInfo.announceForAccessibility(
options.description ? `${options.title}. ${options.description}` : options.title,
);
return id;
},
[haptic, max],
);
const visible = toasts.filter((t) => !t.leaving);
// A single toast has nothing to fan out.
const open = expanded && visible.length > 1;
// Timers run only while the stack is collapsed; opening it pauses them all.
useEffect(() => {
const map = timers.current;
if (open) {
map.forEach(clearTimeout);
map.clear();
return;
}
for (const t of toasts) {
if (t.leaving || map.has(t.id)) continue;
const ms = t.duration === undefined ? duration : t.duration;
if (ms === null) continue;
map.set(
t.id,
setTimeout(() => {
map.delete(t.id);
dismiss(t.id);
}, ms),
);
}
}, [toasts, open, duration, dismiss]);
useEffect(() => {
const map = timers.current;
return () => map.forEach(clearTimeout);
}, []);
const api = useMemo(() => ({ show, dismiss, dismissAll }), [show, dismiss, dismissAll]);
const edge = (safeArea ? (placement === 'top' ? insets.top : insets.bottom) : 0) + offset;
const order = new Map(visible.map((t, i) => [t.id, i]));
// Expanded offsets: each toast sits below (or above) the ones in front of it.
const stackOffsets = new Map<number, number>();
let acc = 0;
for (const t of visible) {
stackOffsets.set(t.id, acc);
acc += (heights[t.id] ?? 64) + GAP;
}
// The host must cover the stack: Android does not deliver touches outside a parent's bounds.
const front = visible[0] ? (heights[visible[0].id] ?? 64) : 0;
const hostHeight = open
? Math.max(0, acc - GAP)
: front + PEEK * Math.max(0, Math.min(visible.length, max) - 1);
return (
<ToastContext.Provider value={api}>
{children}
<View
pointerEvents="box-none"
style={[
styles.host,
placement === 'top' ? { top: edge } : { bottom: edge },
{ height: hostHeight },
]}
>
{/* Render oldest first so the newest paints on top. */}
{[...toasts].reverse().map((t) => (
<ToastItem
key={t.id}
toast={t}
index={order.get(t.id) ?? max}
count={visible.length}
max={max}
expanded={open}
placement={placement}
stackOffset={stackOffsets.get(t.id) ?? 0}
frontHeight={front}
naturalHeight={heights[t.id] ?? 0}
inset={space[4]}
onMeasure={(h) =>
setHeights((all) =>
Math.abs((all[t.id] ?? 0) - h) < 0.5 ? all : { ...all, [t.id]: h },
)
}
onPressStack={() => setExpanded((e) => !e)}
onDismiss={() => dismiss(t.id)}
onGone={() => remove(t.id)}
/>
))}
</View>
</ToastContext.Provider>
);
}
function ToastItem({
toast,
index,
count,
max,
expanded,
placement,
stackOffset,
frontHeight,
naturalHeight,
inset,
onMeasure,
onPressStack,
onDismiss,
onGone,
}: {
toast: ToastRecord;
index: number;
count: number;
max: number;
expanded: boolean;
placement: 'top' | 'bottom';
stackOffset: number;
frontHeight: number;
naturalHeight: number;
inset: number;
onMeasure: (height: number) => void;
onPressStack: () => void;
onDismiss: () => void;
onGone: () => void;
}) {
const { colors, radius, font, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const { width: screen } = useWindowDimensions();
const dir = placement === 'top' ? 1 : -1;
const tone = toast.tone ?? 'neutral';
const accent = {
neutral: colors.textMuted,
success: colors.success,
warning: colors.warning,
danger: colors.danger,
}[tone];
const y = useSharedValue(-dir * 48);
const scale = useSharedValue(reduced ? 1 : 0.96);
const opacity = useSharedValue(0);
const dx = useSharedValue(0);
// Cards tucked behind the front one shrink to its height and hide their
// content, so only their edges peek out.
const height = useSharedValue(0);
const content = useSharedValue(1);
// Collapsed: newer toasts in front, older ones peeking behind, scaled down.
// Expanded: a list, each toast at its accumulated offset.
useEffect(() => {
const cfg = spring('smooth', reduced);
if (toast.leaving) {
opacity.set(
withTiming(0, timing('quick', reduced, 'exit'), (done) => {
'worklet';
if (done) scheduleOnRN(onGone);
}),
);
if (dx.get() === 0) y.set(withSpring(-dir * 24, cfg));
return;
}
const tucked = !expanded && index > 0;
if (naturalHeight > 0) {
const target = tucked && frontHeight > 0 ? frontHeight : naturalHeight;
height.set(height.get() === 0 ? target : withSpring(target, cfg));
}
content.set(withTiming(tucked ? 0 : 1, timing('quick', reduced)));
const hidden = !expanded && index >= max;
opacity.set(withTiming(hidden ? 0 : expanded ? 1 : 1 - index * 0.18, timing('base', reduced)));
y.set(withSpring(dir * (expanded ? stackOffset : index * PEEK), cfg));
scale.set(withSpring(expanded || reduced ? 1 : 1 - index * 0.05, cfg));
// onGone is stable per toast for this purpose.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toast.leaving, index, expanded, stackOffset, frontHeight, naturalHeight, max, dir, reduced]);
const swipe = Gesture.Pan()
.withTestId(`toast-${toast.id}`)
.enabled(!toast.leaving && (index === 0 || expanded))
.activeOffsetX([-12, 12])
.failOffsetY([-12, 12])
.onUpdate((e) => {
dx.set(e.translationX);
})
.onEnd((e) => {
const fling = Math.abs(e.velocityX) > 800;
if (Math.abs(e.translationX) > screen * 0.3 || fling) {
const to = Math.sign(e.translationX || e.velocityX) * screen;
dx.set(
withSpring(to, { ...spring('snappy', reduced, e.velocityX), overshootClamping: true }),
);
scheduleOnRN(onDismiss);
} else {
dx.set(withSpring(0, spring('bouncy', reduced)));
}
});
const tap = Gesture.Tap()
.withTestId(`toast-tap-${toast.id}`)
.enabled(count > 1 && !toast.leaving)
.onEnd((_e, success) => {
if (success) scheduleOnRN(onPressStack);
});
const style = useAnimatedStyle(() => ({
opacity: opacity.get() * (1 - Math.min(1, Math.abs(dx.get()) / (screen * 0.8))),
// Keep the key present from the first frame; Reanimated ignores keys added later.
maxHeight: height.get() > 0 ? height.get() : 10_000,
transform: [{ translateY: y.get() }, { translateX: dx.get() }, { scale: scale.get() }],
}));
const contentStyle = useAnimatedStyle(() => ({ opacity: content.get() }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape')
onDismiss();
};
return (
<GestureDetector gesture={Gesture.Exclusive(swipe, tap)}>
<Animated.View
collapsable={false}
accessible
accessibilityRole="alert"
accessibilityLabel={
toast.description ? `${toast.title}. ${toast.description}` : toast.title
}
accessibilityActions={[{ name: 'dismiss', label: 'Dismiss' }, { name: 'escape' }]}
onAccessibilityAction={onAccessibilityAction}
importantForAccessibility={index >= max && !expanded ? 'no-hide-descendants' : 'auto'}
style={[
styles.toast,
placement === 'top' ? styles.anchorTop : styles.anchorBottom,
{
left: inset,
right: inset,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
},
style,
]}
>
{/* Measured without the card's clamped height, so it reports the natural size. */}
<Animated.View
onLayout={(e) => onMeasure(e.nativeEvent.layout.height)}
style={[styles.content, { padding: space[4], gap: space[3] }, contentStyle]}
>
<View
style={[styles.dot, { backgroundColor: accent, boxShadow: `0px 0px 8px ${accent}` }]}
/>
<View style={styles.texts}>
<Text
numberOfLines={2}
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{toast.title}
</Text>
{toast.description ? (
<Text
numberOfLines={3}
style={{
color: colors.textMuted,
fontSize: font.size.sm,
marginTop: 2,
lineHeight: 19,
}}
>
{toast.description}
</Text>
) : null}
</View>
{toast.action ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={toast.action.label}
hitSlop={8}
onPress={() => {
toast.action?.onPress();
onDismiss();
}}
style={({ pressed }) => [
styles.action,
{
borderRadius: radius.pill,
backgroundColor: pressed ? colors.emberSoft : 'transparent',
paddingHorizontal: space[3],
},
]}
>
<Text
style={{
color: colors.ember,
fontSize: font.size.sm,
fontWeight: font.weight.bold,
}}
>
{toast.action.label}
</Text>
</Pressable>
) : null}
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
host: { position: 'absolute', left: 0, right: 0 },
toast: {
position: 'absolute',
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
content: { flexDirection: 'row', alignItems: 'center' },
anchorTop: { top: 0 },
anchorBottom: { bottom: 0 },
dot: { width: 10, height: 10, borderRadius: 5 },
texts: { flex: 1 },
action: { minHeight: minTouchTarget, justifyContent: 'center' },
});Installation
npx kinetik-ui add toast-stackpnpm dlx kinetik-ui add toast-stackbunx kinetik-ui add toast-stackyarn dlx kinetik-ui add toast-stackThe CLI copies the source and installs native dependencies with npx expo install, so versions match your Expo SDK. With shadcn: npx shadcn@latest add https://kinetik-ui.dev/r/toast-stack.json
Install manually
- Install dependencies
npx expo install react-native-gesture-handler react-native-reanimated react-native-safe-area-context react-native-worklets - Copy the source files
components/kinetik/toast-stack.tsx
Usage
import { ToastProvider, useToast } from '@/components/kinetik/toast-stack';
// Once, near the root (inside GestureHandlerRootView and SafeAreaProvider):
<ToastProvider>
<App />
</ToastProvider>;
// Anywhere below it:
const toast = useToast();
toast.show({ title: 'Card removed', action: { label: 'Undo', onPress: restore } });
toast.show({ title: 'Payment failed', description: 'Try another card.', tone: 'danger' });show returns an id you can pass to dismiss. dismissAll clears the stack.
When to use
- Short, non-blocking confirmations and errors that do not need a decision.
- Offering a quick Undo after an action.
When not to use
- Errors the user must fix before continuing. Show them inline next to the field or action.
- Long messages or several actions. Use a sheet or a dialog.
Notes
- Up to
maxtoasts (3) stay in the stack; older ones animate out. - Tapping the stack fans it out into a list and pauses every timer until it collapses again.
- Swipe a toast sideways past 30% of the screen, or fling it, to dismiss.
success,warninganddangertoasts play the matching haptic; every toast is announced to screen readers.- Set
safeArea={false}when the provider wraps a small region instead of the whole screen.
Props
ToastOptions
| Prop | Type | Default | Description |
|---|---|---|---|
titlerequired | string | — | |
description | string | — | |
tone | ToastTone | — | Defaults to `neutral`. |
duration | number | null | — | Auto-dismiss after this many ms. `null` keeps it until dismissed. Defaults to the provider's duration. |
action | { label: string; onPress: () => void } | — | One optional action, e.g. Undo. Pressing it dismisses the toast. |
ToastProviderProps
| Prop | Type | Default | Description |
|---|---|---|---|
childrenrequired | ReactNode | — | |
placement | 'top' | 'bottom' | 'top' | Defaults to `top`. |
duration | number | 4000 | Default auto-dismiss time in ms. Defaults to 4000. |
max | number | 3 | Toasts kept in the stack; older ones are dropped. Defaults to 3. |
offset | number | 8 | Distance from the safe-area edge, in pt. Defaults to 8. |
haptics | boolean | true | Plays a haptic for success, warning and danger toasts. Defaults to true. |
safeArea | boolean | true | Keep clear of the status bar and home indicator. Turn off when the provider wraps a small region rather than the screen. Defaults to true. |
Performance and accessibility
Performance
- Budget
- Springs run only while toasts enter, re-stack, expand or leave; swipes track on the UI thread.
- Measured
Pixel 9 emulator, API 35, release build · interaction median 1.06× the system Settings app on the same emulator (indicative only) · no frames drawn at rest · 140 MB app memory
Real-device measurement pending.
Accessibility
- Reduced motion
- Toasts fade in and out in place; no slide, scale or fly-out.
- Screen readers
- Each toast is announced when shown and exposed as an alert with a 'Dismiss' action; the Undo-style action is its own button.
- Touch target
- Action buttons are at least 44 pt tall.
Try it on a device
Scan with a phone that has the Kinetik playground installed to open this demo with real haptics and sensors.
Source files
components/kinetik/toast-stack.tsx
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import {
AccessibilityInfo,
Pressable,
StyleSheet,
Text,
View,
useWindowDimensions,
type AccessibilityActionEvent,
} from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
import { useHaptic, type HapticKind } from '../../lib/kinetik/hooks/use-haptic';
import { useReduceMotion } from '../../lib/kinetik/hooks/use-reduce-motion';
import { spring, timing } from '../../lib/kinetik/motion/motion';
import { useKinetikTheme } from '../../lib/kinetik/tokens/theme';
import { minTouchTarget } from '../../lib/kinetik/tokens/tokens';
export type ToastTone = 'neutral' | 'success' | 'warning' | 'danger';
export type ToastOptions = {
title: string;
description?: string;
/** Defaults to `neutral`. */
tone?: ToastTone;
/** Auto-dismiss after this many ms. `null` keeps it until dismissed. Defaults to the provider's duration. */
duration?: number | null;
/** One optional action, e.g. Undo. Pressing it dismisses the toast. */
action?: { label: string; onPress: () => void };
};
type ToastRecord = ToastOptions & { id: number; leaving: boolean };
type ToastApi = {
/** Shows a toast and returns its id. */
show: (options: ToastOptions) => number;
dismiss: (id: number) => void;
dismissAll: () => void;
};
const ToastContext = createContext<ToastApi | null>(null);
/** Shows and dismisses toasts. Must be used inside a `ToastProvider`. */
export function useToast(): ToastApi {
const api = useContext(ToastContext);
if (!api) throw new Error('useToast must be used inside <ToastProvider>');
return api;
}
export type ToastProviderProps = {
children: ReactNode;
/** Defaults to `top`. */
placement?: 'top' | 'bottom';
/** Default auto-dismiss time in ms. Defaults to 4000. */
duration?: number;
/** Toasts kept in the stack; older ones are dropped. Defaults to 3. */
max?: number;
/** Distance from the safe-area edge, in pt. Defaults to 8. */
offset?: number;
/** Plays a haptic for success, warning and danger toasts. Defaults to true. */
haptics?: boolean;
/** Keep clear of the status bar and home indicator. Turn off when the provider wraps a small region rather than the screen. Defaults to true. */
safeArea?: boolean;
};
const PEEK = 10;
const GAP = 8;
const toneHaptic: Record<ToastTone, HapticKind | null> = {
neutral: null,
success: 'success',
warning: 'warning',
danger: 'error',
};
/**
* Hosts a stack of toasts. New toasts slide in on top of the stack and older
* ones tuck behind. Tap the stack to fan it out; swipe a toast sideways to
* dismiss it. Timers pause while the stack is open.
*/
export function ToastProvider({
children,
placement = 'top',
duration = 4000,
max = 3,
offset = 8,
haptics = true,
safeArea = true,
}: ToastProviderProps) {
const haptic = useHaptic(haptics);
const insets = useSafeAreaInsets();
const { space } = useKinetikTheme();
const [toasts, setToasts] = useState<ToastRecord[]>([]);
const [expanded, setExpanded] = useState(false);
const [heights, setHeights] = useState<Record<number, number>>({});
const nextId = useRef(1);
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
const remove = useCallback((id: number) => {
setToasts((all) => all.filter((t) => t.id !== id));
setHeights(({ [id]: _, ...rest }) => rest);
}, []);
const dismiss = useCallback((id: number) => {
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
timers.current.delete(id);
setToasts((all) => all.map((t) => (t.id === id ? { ...t, leaving: true } : t)));
}, []);
const dismissAll = useCallback(() => {
setExpanded(false);
timers.current.forEach(clearTimeout);
timers.current.clear();
setToasts((all) => all.map((t) => ({ ...t, leaving: true })));
}, []);
const show = useCallback(
(options: ToastOptions) => {
const id = nextId.current++;
setToasts((all) => {
const next = [{ ...options, id, leaving: false }, ...all];
// Drop the oldest beyond `max`, letting them animate out.
return next.map((t, i) => (i >= max && !t.leaving ? { ...t, leaving: true } : t));
});
const kind = toneHaptic[options.tone ?? 'neutral'];
if (kind) haptic(kind);
AccessibilityInfo.announceForAccessibility(
options.description ? `${options.title}. ${options.description}` : options.title,
);
return id;
},
[haptic, max],
);
const visible = toasts.filter((t) => !t.leaving);
// A single toast has nothing to fan out.
const open = expanded && visible.length > 1;
// Timers run only while the stack is collapsed; opening it pauses them all.
useEffect(() => {
const map = timers.current;
if (open) {
map.forEach(clearTimeout);
map.clear();
return;
}
for (const t of toasts) {
if (t.leaving || map.has(t.id)) continue;
const ms = t.duration === undefined ? duration : t.duration;
if (ms === null) continue;
map.set(
t.id,
setTimeout(() => {
map.delete(t.id);
dismiss(t.id);
}, ms),
);
}
}, [toasts, open, duration, dismiss]);
useEffect(() => {
const map = timers.current;
return () => map.forEach(clearTimeout);
}, []);
const api = useMemo(() => ({ show, dismiss, dismissAll }), [show, dismiss, dismissAll]);
const edge = (safeArea ? (placement === 'top' ? insets.top : insets.bottom) : 0) + offset;
const order = new Map(visible.map((t, i) => [t.id, i]));
// Expanded offsets: each toast sits below (or above) the ones in front of it.
const stackOffsets = new Map<number, number>();
let acc = 0;
for (const t of visible) {
stackOffsets.set(t.id, acc);
acc += (heights[t.id] ?? 64) + GAP;
}
// The host must cover the stack: Android does not deliver touches outside a parent's bounds.
const front = visible[0] ? (heights[visible[0].id] ?? 64) : 0;
const hostHeight = open
? Math.max(0, acc - GAP)
: front + PEEK * Math.max(0, Math.min(visible.length, max) - 1);
return (
<ToastContext.Provider value={api}>
{children}
<View
pointerEvents="box-none"
style={[
styles.host,
placement === 'top' ? { top: edge } : { bottom: edge },
{ height: hostHeight },
]}
>
{/* Render oldest first so the newest paints on top. */}
{[...toasts].reverse().map((t) => (
<ToastItem
key={t.id}
toast={t}
index={order.get(t.id) ?? max}
count={visible.length}
max={max}
expanded={open}
placement={placement}
stackOffset={stackOffsets.get(t.id) ?? 0}
frontHeight={front}
naturalHeight={heights[t.id] ?? 0}
inset={space[4]}
onMeasure={(h) =>
setHeights((all) =>
Math.abs((all[t.id] ?? 0) - h) < 0.5 ? all : { ...all, [t.id]: h },
)
}
onPressStack={() => setExpanded((e) => !e)}
onDismiss={() => dismiss(t.id)}
onGone={() => remove(t.id)}
/>
))}
</View>
</ToastContext.Provider>
);
}
function ToastItem({
toast,
index,
count,
max,
expanded,
placement,
stackOffset,
frontHeight,
naturalHeight,
inset,
onMeasure,
onPressStack,
onDismiss,
onGone,
}: {
toast: ToastRecord;
index: number;
count: number;
max: number;
expanded: boolean;
placement: 'top' | 'bottom';
stackOffset: number;
frontHeight: number;
naturalHeight: number;
inset: number;
onMeasure: (height: number) => void;
onPressStack: () => void;
onDismiss: () => void;
onGone: () => void;
}) {
const { colors, radius, font, space, shadow } = useKinetikTheme();
const reduced = useReduceMotion();
const { width: screen } = useWindowDimensions();
const dir = placement === 'top' ? 1 : -1;
const tone = toast.tone ?? 'neutral';
const accent = {
neutral: colors.textMuted,
success: colors.success,
warning: colors.warning,
danger: colors.danger,
}[tone];
const y = useSharedValue(-dir * 48);
const scale = useSharedValue(reduced ? 1 : 0.96);
const opacity = useSharedValue(0);
const dx = useSharedValue(0);
// Cards tucked behind the front one shrink to its height and hide their
// content, so only their edges peek out.
const height = useSharedValue(0);
const content = useSharedValue(1);
// Collapsed: newer toasts in front, older ones peeking behind, scaled down.
// Expanded: a list, each toast at its accumulated offset.
useEffect(() => {
const cfg = spring('smooth', reduced);
if (toast.leaving) {
opacity.set(
withTiming(0, timing('quick', reduced, 'exit'), (done) => {
'worklet';
if (done) scheduleOnRN(onGone);
}),
);
if (dx.get() === 0) y.set(withSpring(-dir * 24, cfg));
return;
}
const tucked = !expanded && index > 0;
if (naturalHeight > 0) {
const target = tucked && frontHeight > 0 ? frontHeight : naturalHeight;
height.set(height.get() === 0 ? target : withSpring(target, cfg));
}
content.set(withTiming(tucked ? 0 : 1, timing('quick', reduced)));
const hidden = !expanded && index >= max;
opacity.set(withTiming(hidden ? 0 : expanded ? 1 : 1 - index * 0.18, timing('base', reduced)));
y.set(withSpring(dir * (expanded ? stackOffset : index * PEEK), cfg));
scale.set(withSpring(expanded || reduced ? 1 : 1 - index * 0.05, cfg));
// onGone is stable per toast for this purpose.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [toast.leaving, index, expanded, stackOffset, frontHeight, naturalHeight, max, dir, reduced]);
const swipe = Gesture.Pan()
.withTestId(`toast-${toast.id}`)
.enabled(!toast.leaving && (index === 0 || expanded))
.activeOffsetX([-12, 12])
.failOffsetY([-12, 12])
.onUpdate((e) => {
dx.set(e.translationX);
})
.onEnd((e) => {
const fling = Math.abs(e.velocityX) > 800;
if (Math.abs(e.translationX) > screen * 0.3 || fling) {
const to = Math.sign(e.translationX || e.velocityX) * screen;
dx.set(
withSpring(to, { ...spring('snappy', reduced, e.velocityX), overshootClamping: true }),
);
scheduleOnRN(onDismiss);
} else {
dx.set(withSpring(0, spring('bouncy', reduced)));
}
});
const tap = Gesture.Tap()
.withTestId(`toast-tap-${toast.id}`)
.enabled(count > 1 && !toast.leaving)
.onEnd((_e, success) => {
if (success) scheduleOnRN(onPressStack);
});
const style = useAnimatedStyle(() => ({
opacity: opacity.get() * (1 - Math.min(1, Math.abs(dx.get()) / (screen * 0.8))),
// Keep the key present from the first frame; Reanimated ignores keys added later.
maxHeight: height.get() > 0 ? height.get() : 10_000,
transform: [{ translateY: y.get() }, { translateX: dx.get() }, { scale: scale.get() }],
}));
const contentStyle = useAnimatedStyle(() => ({ opacity: content.get() }));
const onAccessibilityAction = (e: AccessibilityActionEvent) => {
if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape')
onDismiss();
};
return (
<GestureDetector gesture={Gesture.Exclusive(swipe, tap)}>
<Animated.View
collapsable={false}
accessible
accessibilityRole="alert"
accessibilityLabel={
toast.description ? `${toast.title}. ${toast.description}` : toast.title
}
accessibilityActions={[{ name: 'dismiss', label: 'Dismiss' }, { name: 'escape' }]}
onAccessibilityAction={onAccessibilityAction}
importantForAccessibility={index >= max && !expanded ? 'no-hide-descendants' : 'auto'}
style={[
styles.toast,
placement === 'top' ? styles.anchorTop : styles.anchorBottom,
{
left: inset,
right: inset,
backgroundColor: colors.surfaceRaised,
borderColor: colors.border,
borderRadius: radius.lg,
boxShadow: shadow.lg,
},
style,
]}
>
{/* Measured without the card's clamped height, so it reports the natural size. */}
<Animated.View
onLayout={(e) => onMeasure(e.nativeEvent.layout.height)}
style={[styles.content, { padding: space[4], gap: space[3] }, contentStyle]}
>
<View
style={[styles.dot, { backgroundColor: accent, boxShadow: `0px 0px 8px ${accent}` }]}
/>
<View style={styles.texts}>
<Text
numberOfLines={2}
style={{
color: colors.text,
fontSize: font.size.md,
fontWeight: font.weight.semibold,
}}
>
{toast.title}
</Text>
{toast.description ? (
<Text
numberOfLines={3}
style={{
color: colors.textMuted,
fontSize: font.size.sm,
marginTop: 2,
lineHeight: 19,
}}
>
{toast.description}
</Text>
) : null}
</View>
{toast.action ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={toast.action.label}
hitSlop={8}
onPress={() => {
toast.action?.onPress();
onDismiss();
}}
style={({ pressed }) => [
styles.action,
{
borderRadius: radius.pill,
backgroundColor: pressed ? colors.emberSoft : 'transparent',
paddingHorizontal: space[3],
},
]}
>
<Text
style={{
color: colors.ember,
fontSize: font.size.sm,
fontWeight: font.weight.bold,
}}
>
{toast.action.label}
</Text>
</Pressable>
) : null}
</Animated.View>
</Animated.View>
</GestureDetector>
);
}
const styles = StyleSheet.create({
host: { position: 'absolute', left: 0, right: 0 },
toast: {
position: 'absolute',
borderWidth: StyleSheet.hairlineWidth,
overflow: 'hidden',
},
content: { flexDirection: 'row', alignItems: 'center' },
anchorTop: { top: 0 },
anchorBottom: { bottom: 0 },
dot: { width: 10, height: 10, borderRadius: 5 },
texts: { flex: 1 },
action: { minHeight: minTouchTarget, justifyContent: 'center' },
});