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