about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorEric Bailey <git@esb.lol>2025-07-31 10:15:35 -0500
committerGitHub <noreply@github.com>2025-07-31 10:15:35 -0500
commit3bcfcba6d8176bac03202b496110915da748b0f1 (patch)
tree68c75c7c80945a8a5f5a32522dd9aa29f119e02a /src
parent33e071494881b13696e24b334857e594f29a4b1d (diff)
downloadvoidsky-3bcfcba6d8176bac03202b496110915da748b0f1.tar.zst
Some toasts cleanup and reorg (#8748)
* Reorg

* Move animation into css file

* Update style comment

* Extract core component, use platform-specific wrappers

* Pull out platform specific styles

* Just move styles into Toast component itself

* Rename cleanup

* Update API

* Add duration optional prop

* Add some type docs

* add exp eased slide aniamtions

* Make toasts full width on mobile web

---------

Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/App.web.tsx2
-rw-r--r--src/components/Toast/Toast.tsx205
-rw-r--r--src/components/Toast/const.ts1
-rw-r--r--src/components/Toast/index.e2e.tsx5
-rw-r--r--src/components/Toast/index.tsx197
-rw-r--r--src/components/Toast/index.web.tsx107
-rw-r--r--src/components/Toast/types.ts24
-rw-r--r--src/style.css20
-rw-r--r--src/view/com/util/Toast.e2e.tsx1
-rw-r--r--src/view/com/util/Toast.style.tsx201
-rw-r--r--src/view/com/util/Toast.tsx268
-rw-r--r--src/view/com/util/Toast.web.tsx180
-rw-r--r--src/view/screens/Storybook/Toasts.tsx188
-rw-r--r--src/view/screens/Storybook/index.tsx3
14 files changed, 707 insertions, 695 deletions
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 04de8529f..1f795cb3e 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -50,7 +50,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
 import * as Toast from '#/view/com/util/Toast'
-import {ToastContainer} from '#/view/com/util/Toast.web'
 import {Shell} from '#/view/shell/index'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
@@ -61,6 +60,7 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
 import {Provider as PortalProvider} from '#/components/Portal'
 import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
 import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
+import {ToastContainer} from '#/components/Toast'
 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import {Provider as HideBottomBarBorderProvider} from './lib/hooks/useHideBottomBarBorder'
 
diff --git a/src/components/Toast/Toast.tsx b/src/components/Toast/Toast.tsx
new file mode 100644
index 000000000..0dc9d4b07
--- /dev/null
+++ b/src/components/Toast/Toast.tsx
@@ -0,0 +1,205 @@
+import {createContext, useContext, useMemo} from 'react'
+import {View} from 'react-native'
+
+import {atoms as a, select, useTheme} from '#/alf'
+import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
+import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
+import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
+import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
+import {type ToastType} from '#/components/Toast/types'
+import {Text} from '#/components/Typography'
+
+type ContextType = {
+  type: ToastType
+}
+
+export const ICONS = {
+  default: SuccessIcon,
+  success: SuccessIcon,
+  error: ErrorIcon,
+  warning: WarningIcon,
+  info: CircleInfo,
+}
+
+const Context = createContext<ContextType>({
+  type: 'default',
+})
+
+export function Toast({
+  type,
+  content,
+}: {
+  type: ToastType
+  content: React.ReactNode
+}) {
+  const t = useTheme()
+  const styles = useToastStyles({type})
+  const Icon = ICONS[type]
+
+  return (
+    <Context.Provider value={useMemo(() => ({type}), [type])}>
+      <View
+        style={[
+          a.flex_1,
+          a.py_lg,
+          a.pl_xl,
+          a.pr_2xl,
+          a.rounded_md,
+          a.border,
+          a.flex_row,
+          a.gap_sm,
+          t.atoms.shadow_sm,
+          {
+            backgroundColor: styles.backgroundColor,
+            borderColor: styles.borderColor,
+          },
+        ]}>
+        <Icon size="md" fill={styles.iconColor} />
+
+        <View style={[a.flex_1]}>
+          {typeof content === 'string' ? (
+            <ToastText>{content}</ToastText>
+          ) : (
+            content
+          )}
+        </View>
+      </View>
+    </Context.Provider>
+  )
+}
+
+export function ToastText({children}: {children: React.ReactNode}) {
+  const {type} = useContext(Context)
+  const {textColor} = useToastStyles({type})
+  return (
+    <Text
+      style={[
+        a.text_md,
+        a.font_bold,
+        a.leading_snug,
+        {
+          color: textColor,
+        },
+      ]}>
+      {children}
+    </Text>
+  )
+}
+
+function useToastStyles({type}: {type: ToastType}) {
+  const t = useTheme()
+  return useMemo(() => {
+    return {
+      default: {
+        backgroundColor: select(t.name, {
+          light: t.atoms.bg_contrast_25.backgroundColor,
+          dim: t.atoms.bg_contrast_100.backgroundColor,
+          dark: t.atoms.bg_contrast_100.backgroundColor,
+        }),
+        borderColor: select(t.name, {
+          light: t.atoms.border_contrast_low.borderColor,
+          dim: t.atoms.border_contrast_high.borderColor,
+          dark: t.atoms.border_contrast_high.borderColor,
+        }),
+        iconColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+        textColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+      },
+      success: {
+        backgroundColor: select(t.name, {
+          light: t.palette.primary_100,
+          dim: t.palette.primary_100,
+          dark: t.palette.primary_50,
+        }),
+        borderColor: select(t.name, {
+          light: t.palette.primary_500,
+          dim: t.palette.primary_500,
+          dark: t.palette.primary_500,
+        }),
+        iconColor: select(t.name, {
+          light: t.palette.primary_500,
+          dim: t.palette.primary_600,
+          dark: t.palette.primary_600,
+        }),
+        textColor: select(t.name, {
+          light: t.palette.primary_500,
+          dim: t.palette.primary_600,
+          dark: t.palette.primary_600,
+        }),
+      },
+      error: {
+        backgroundColor: select(t.name, {
+          light: t.palette.negative_200,
+          dim: t.palette.negative_25,
+          dark: t.palette.negative_25,
+        }),
+        borderColor: select(t.name, {
+          light: t.palette.negative_300,
+          dim: t.palette.negative_300,
+          dark: t.palette.negative_300,
+        }),
+        iconColor: select(t.name, {
+          light: t.palette.negative_600,
+          dim: t.palette.negative_600,
+          dark: t.palette.negative_600,
+        }),
+        textColor: select(t.name, {
+          light: t.palette.negative_600,
+          dim: t.palette.negative_600,
+          dark: t.palette.negative_600,
+        }),
+      },
+      warning: {
+        backgroundColor: select(t.name, {
+          light: t.atoms.bg_contrast_25.backgroundColor,
+          dim: t.atoms.bg_contrast_100.backgroundColor,
+          dark: t.atoms.bg_contrast_100.backgroundColor,
+        }),
+        borderColor: select(t.name, {
+          light: t.atoms.border_contrast_low.borderColor,
+          dim: t.atoms.border_contrast_high.borderColor,
+          dark: t.atoms.border_contrast_high.borderColor,
+        }),
+        iconColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+        textColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+      },
+      info: {
+        backgroundColor: select(t.name, {
+          light: t.atoms.bg_contrast_25.backgroundColor,
+          dim: t.atoms.bg_contrast_100.backgroundColor,
+          dark: t.atoms.bg_contrast_100.backgroundColor,
+        }),
+        borderColor: select(t.name, {
+          light: t.atoms.border_contrast_low.borderColor,
+          dim: t.atoms.border_contrast_high.borderColor,
+          dark: t.atoms.border_contrast_high.borderColor,
+        }),
+        iconColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+        textColor: select(t.name, {
+          light: t.atoms.text_contrast_medium.color,
+          dim: t.atoms.text_contrast_medium.color,
+          dark: t.atoms.text_contrast_medium.color,
+        }),
+      },
+    }[type]
+  }, [t, type])
+}
diff --git a/src/components/Toast/const.ts b/src/components/Toast/const.ts
new file mode 100644
index 000000000..034d0a2fc
--- /dev/null
+++ b/src/components/Toast/const.ts
@@ -0,0 +1 @@
+export const DEFAULT_TOAST_DURATION = 3000
diff --git a/src/components/Toast/index.e2e.tsx b/src/components/Toast/index.e2e.tsx
new file mode 100644
index 000000000..57daf5bf0
--- /dev/null
+++ b/src/components/Toast/index.e2e.tsx
@@ -0,0 +1,5 @@
+export function ToastContainer() {
+  return null
+}
+
+export function show() {}
diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx
new file mode 100644
index 000000000..131a796b3
--- /dev/null
+++ b/src/components/Toast/index.tsx
@@ -0,0 +1,197 @@
+import {useEffect, useMemo, useRef, useState} from 'react'
+import {AccessibilityInfo} from 'react-native'
+import {
+  Gesture,
+  GestureDetector,
+  GestureHandlerRootView,
+} from 'react-native-gesture-handler'
+import Animated, {
+  Easing,
+  runOnJS,
+  SlideInUp,
+  SlideOutUp,
+  useAnimatedReaction,
+  useAnimatedStyle,
+  useSharedValue,
+  withDecay,
+  withSpring,
+} from 'react-native-reanimated'
+import RootSiblings from 'react-native-root-siblings'
+import {useSafeAreaInsets} from 'react-native-safe-area-context'
+
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {atoms as a} from '#/alf'
+import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
+import {Toast} from '#/components/Toast/Toast'
+import {type ToastApi, type ToastType} from '#/components/Toast/types'
+
+const TOAST_ANIMATION_DURATION = 300
+
+export function ToastContainer() {
+  return null
+}
+
+export const toast: ToastApi = {
+  show(props) {
+    if (process.env.NODE_ENV === 'test') {
+      return
+    }
+
+    AccessibilityInfo.announceForAccessibility(props.a11yLabel)
+
+    const item = new RootSiblings(
+      (
+        <AnimatedToast
+          type={props.type}
+          content={props.content}
+          a11yLabel={props.a11yLabel}
+          duration={props.duration ?? DEFAULT_TOAST_DURATION}
+          destroy={() => item.destroy()}
+        />
+      ),
+    )
+  },
+}
+
+function AnimatedToast({
+  type,
+  content,
+  a11yLabel,
+  duration,
+  destroy,
+}: {
+  type: ToastType
+  content: React.ReactNode
+  a11yLabel: string
+  duration: number
+  destroy: () => void
+}) {
+  const {top} = useSafeAreaInsets()
+  const isPanning = useSharedValue(false)
+  const dismissSwipeTranslateY = useSharedValue(0)
+  const [cardHeight, setCardHeight] = useState(0)
+
+  // for the exit animation to work on iOS the animated component
+  // must not be the root component
+  // so we need to wrap it in a view and unmount the toast ahead of time
+  const [alive, setAlive] = useState(true)
+
+  const hideAndDestroyImmediately = () => {
+    setAlive(false)
+    setTimeout(() => {
+      destroy()
+    }, 1e3)
+  }
+
+  const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
+  const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
+    clearTimeout(destroyTimeoutRef.current)
+    destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, duration)
+  })
+  const pauseDestroy = useNonReactiveCallback(() => {
+    clearTimeout(destroyTimeoutRef.current)
+  })
+
+  useEffect(() => {
+    hideAndDestroyAfterTimeout()
+  }, [hideAndDestroyAfterTimeout])
+
+  const panGesture = useMemo(() => {
+    return Gesture.Pan()
+      .activeOffsetY([-10, 10])
+      .failOffsetX([-10, 10])
+      .maxPointers(1)
+      .onStart(() => {
+        'worklet'
+        if (!alive) return
+        isPanning.set(true)
+        runOnJS(pauseDestroy)()
+      })
+      .onUpdate(e => {
+        'worklet'
+        if (!alive) return
+        dismissSwipeTranslateY.value = e.translationY
+      })
+      .onEnd(e => {
+        'worklet'
+        if (!alive) return
+        runOnJS(hideAndDestroyAfterTimeout)()
+        isPanning.set(false)
+        if (e.velocityY < -100) {
+          if (dismissSwipeTranslateY.value === 0) {
+            // HACK: If the initial value is 0, withDecay() animation doesn't start.
+            // This is a bug in Reanimated, but for now we'll work around it like this.
+            dismissSwipeTranslateY.value = 1
+          }
+          dismissSwipeTranslateY.value = withDecay({
+            velocity: e.velocityY,
+            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
+            deceleration: 1,
+          })
+        } else {
+          dismissSwipeTranslateY.value = withSpring(0, {
+            stiffness: 500,
+            damping: 50,
+          })
+        }
+      })
+  }, [
+    dismissSwipeTranslateY,
+    isPanning,
+    alive,
+    hideAndDestroyAfterTimeout,
+    pauseDestroy,
+  ])
+
+  const topOffset = top + 10
+
+  useAnimatedReaction(
+    () =>
+      !isPanning.get() &&
+      dismissSwipeTranslateY.get() < -topOffset - cardHeight,
+    (isSwipedAway, prevIsSwipedAway) => {
+      'worklet'
+      if (isSwipedAway && !prevIsSwipedAway) {
+        runOnJS(destroy)()
+      }
+    },
+  )
+
+  const animatedStyle = useAnimatedStyle(() => {
+    const translation = dismissSwipeTranslateY.get()
+    return {
+      transform: [
+        {
+          translateY: translation > 0 ? translation ** 0.7 : translation,
+        },
+      ],
+    }
+  })
+
+  return (
+    <GestureHandlerRootView
+      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
+      pointerEvents="box-none">
+      {alive && (
+        <Animated.View
+          entering={SlideInUp.easing(Easing.out(Easing.exp)).duration(
+            TOAST_ANIMATION_DURATION,
+          )}
+          exiting={SlideOutUp.easing(Easing.in(Easing.exp)).duration(
+            TOAST_ANIMATION_DURATION * 0.7,
+          )}
+          onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
+          accessibilityRole="alert"
+          accessible={true}
+          accessibilityLabel={a11yLabel}
+          accessibilityHint=""
+          onAccessibilityEscape={hideAndDestroyImmediately}
+          style={[a.flex_1, animatedStyle]}>
+          <GestureDetector gesture={panGesture}>
+            <Toast content={content} type={type} />
+          </GestureDetector>
+        </Animated.View>
+      )}
+    </GestureHandlerRootView>
+  )
+}
diff --git a/src/components/Toast/index.web.tsx b/src/components/Toast/index.web.tsx
new file mode 100644
index 000000000..f6ceda568
--- /dev/null
+++ b/src/components/Toast/index.web.tsx
@@ -0,0 +1,107 @@
+/*
+ * Note: relies on styles in #/styles.css
+ */
+
+import {useEffect, useState} from 'react'
+import {AccessibilityInfo, Pressable, View} from 'react-native'
+import {msg} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {atoms as a, useBreakpoints} from '#/alf'
+import {DEFAULT_TOAST_DURATION} from '#/components/Toast/const'
+import {Toast} from '#/components/Toast/Toast'
+import {type ToastApi, type ToastType} from '#/components/Toast/types'
+
+const TOAST_ANIMATION_STYLES = {
+  entering: {
+    animation: 'toastFadeIn 0.3s ease-out forwards',
+  },
+  exiting: {
+    animation: 'toastFadeOut 0.2s ease-in forwards',
+  },
+}
+
+interface ActiveToast {
+  type: ToastType
+  content: React.ReactNode
+  a11yLabel: string
+}
+type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
+let globalSetActiveToast: GlobalSetActiveToast | undefined
+let toastTimeout: NodeJS.Timeout | undefined
+type ToastContainerProps = {}
+
+export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
+  const {_} = useLingui()
+  const {gtPhone} = useBreakpoints()
+  const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
+  const [isExiting, setIsExiting] = useState(false)
+
+  useEffect(() => {
+    globalSetActiveToast = (t: ActiveToast | undefined) => {
+      if (!t && activeToast) {
+        setIsExiting(true)
+        setTimeout(() => {
+          setActiveToast(t)
+          setIsExiting(false)
+        }, 200)
+      } else {
+        if (t) {
+          AccessibilityInfo.announceForAccessibility(t.a11yLabel)
+        }
+        setActiveToast(t)
+        setIsExiting(false)
+      }
+    }
+  }, [activeToast])
+
+  return (
+    <>
+      {activeToast && (
+        <View
+          style={[
+            a.fixed,
+            {
+              left: a.px_xl.paddingLeft,
+              right: a.px_xl.paddingLeft,
+              bottom: a.px_xl.paddingLeft,
+              ...(isExiting
+                ? TOAST_ANIMATION_STYLES.exiting
+                : TOAST_ANIMATION_STYLES.entering),
+            },
+            gtPhone && [
+              {
+                maxWidth: 380,
+              },
+            ],
+          ]}>
+          <Toast content={activeToast.content} type={activeToast.type} />
+          <Pressable
+            style={[a.absolute, a.inset_0]}
+            accessibilityLabel={_(msg`Dismiss toast`)}
+            accessibilityHint=""
+            onPress={() => setActiveToast(undefined)}
+          />
+        </View>
+      )}
+    </>
+  )
+}
+
+export const toast: ToastApi = {
+  show(props) {
+    if (toastTimeout) {
+      clearTimeout(toastTimeout)
+    }
+
+    globalSetActiveToast?.({
+      type: props.type,
+      content: props.content,
+      a11yLabel: props.a11yLabel,
+    })
+
+    toastTimeout = setTimeout(() => {
+      globalSetActiveToast?.(undefined)
+    }, props.duration || DEFAULT_TOAST_DURATION)
+  },
+}
diff --git a/src/components/Toast/types.ts b/src/components/Toast/types.ts
new file mode 100644
index 000000000..9f1245fa2
--- /dev/null
+++ b/src/components/Toast/types.ts
@@ -0,0 +1,24 @@
+export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
+
+export type ToastApi = {
+  show: (props: {
+    /**
+     * The type of toast to show. This determines the styling and icon used.
+     */
+    type: ToastType
+    /**
+     * A string, `Text`, or `Span` components to render inside the toast. This
+     * allows additional formatting of the content, but should not be used for
+     * interactive elements link links or buttons.
+     */
+    content: React.ReactNode | string
+    /**
+     * Accessibility label for the toast, used for screen readers.
+     */
+    a11yLabel: string
+    /**
+     * Defaults to `DEFAULT_TOAST_DURATION` from `#components/Toast/const`.
+     */
+    duration?: number
+  }) => void
+}
diff --git a/src/style.css b/src/style.css
index 35ffe0d3a..4c5677fbf 100644
--- a/src/style.css
+++ b/src/style.css
@@ -369,3 +369,23 @@ input[type='range'][orient='vertical']::-moz-range-thumb {
     transform: translateY(0);
   }
 }
+
+/*
+ * #/components/Toast/index.web.tsx
+ */
+@keyframes toastFadeIn {
+  from {
+    opacity: 0;
+  }
+  to {
+    opacity: 1;
+  }
+}
+@keyframes toastFadeOut {
+  from {
+    opacity: 1;
+  }
+  to {
+    opacity: 0;
+  }
+}
diff --git a/src/view/com/util/Toast.e2e.tsx b/src/view/com/util/Toast.e2e.tsx
deleted file mode 100644
index c5582ff0a..000000000
--- a/src/view/com/util/Toast.e2e.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export function show() {}
diff --git a/src/view/com/util/Toast.style.tsx b/src/view/com/util/Toast.style.tsx
deleted file mode 100644
index 3869e6890..000000000
--- a/src/view/com/util/Toast.style.tsx
+++ /dev/null
@@ -1,201 +0,0 @@
-import {select, type Theme} from '#/alf'
-import {Check_Stroke2_Corner0_Rounded as SuccessIcon} from '#/components/icons/Check'
-import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
-import {CircleInfo_Stroke2_Corner0_Rounded as ErrorIcon} from '#/components/icons/CircleInfo'
-import {Warning_Stroke2_Corner0_Rounded as WarningIcon} from '#/components/icons/Warning'
-
-export type ToastType = 'default' | 'success' | 'error' | 'warning' | 'info'
-
-export type LegacyToastType =
-  | 'xmark'
-  | 'exclamation-circle'
-  | 'check'
-  | 'clipboard-check'
-  | 'circle-exclamation'
-
-export const convertLegacyToastType = (
-  type: ToastType | LegacyToastType,
-): ToastType => {
-  switch (type) {
-    // these ones are fine
-    case 'default':
-    case 'success':
-    case 'error':
-    case 'warning':
-    case 'info':
-      return type
-    // legacy ones need conversion
-    case 'xmark':
-      return 'error'
-    case 'exclamation-circle':
-      return 'warning'
-    case 'check':
-      return 'success'
-    case 'clipboard-check':
-      return 'success'
-    case 'circle-exclamation':
-      return 'warning'
-    default:
-      return 'default'
-  }
-}
-
-export const TOAST_ANIMATION_CONFIG = {
-  duration: 300,
-  damping: 15,
-  stiffness: 150,
-  mass: 0.8,
-  overshootClamping: false,
-  restSpeedThreshold: 0.01,
-  restDisplacementThreshold: 0.01,
-}
-
-export const TOAST_TYPE_TO_ICON = {
-  default: SuccessIcon,
-  success: SuccessIcon,
-  error: ErrorIcon,
-  warning: WarningIcon,
-  info: CircleInfo,
-}
-
-export const getToastTypeStyles = (t: Theme) => ({
-  default: {
-    backgroundColor: select(t.name, {
-      light: t.atoms.bg_contrast_25.backgroundColor,
-      dim: t.atoms.bg_contrast_100.backgroundColor,
-      dark: t.atoms.bg_contrast_100.backgroundColor,
-    }),
-    borderColor: select(t.name, {
-      light: t.atoms.border_contrast_low.borderColor,
-      dim: t.atoms.border_contrast_high.borderColor,
-      dark: t.atoms.border_contrast_high.borderColor,
-    }),
-    iconColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-    textColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-  },
-  success: {
-    backgroundColor: select(t.name, {
-      light: t.palette.primary_100,
-      dim: t.palette.primary_100,
-      dark: t.palette.primary_50,
-    }),
-    borderColor: select(t.name, {
-      light: t.palette.primary_500,
-      dim: t.palette.primary_500,
-      dark: t.palette.primary_500,
-    }),
-    iconColor: select(t.name, {
-      light: t.palette.primary_500,
-      dim: t.palette.primary_600,
-      dark: t.palette.primary_600,
-    }),
-    textColor: select(t.name, {
-      light: t.palette.primary_500,
-      dim: t.palette.primary_600,
-      dark: t.palette.primary_600,
-    }),
-  },
-  error: {
-    backgroundColor: select(t.name, {
-      light: t.palette.negative_200,
-      dim: t.palette.negative_25,
-      dark: t.palette.negative_25,
-    }),
-    borderColor: select(t.name, {
-      light: t.palette.negative_300,
-      dim: t.palette.negative_300,
-      dark: t.palette.negative_300,
-    }),
-    iconColor: select(t.name, {
-      light: t.palette.negative_600,
-      dim: t.palette.negative_600,
-      dark: t.palette.negative_600,
-    }),
-    textColor: select(t.name, {
-      light: t.palette.negative_600,
-      dim: t.palette.negative_600,
-      dark: t.palette.negative_600,
-    }),
-  },
-  warning: {
-    backgroundColor: select(t.name, {
-      light: t.atoms.bg_contrast_25.backgroundColor,
-      dim: t.atoms.bg_contrast_100.backgroundColor,
-      dark: t.atoms.bg_contrast_100.backgroundColor,
-    }),
-    borderColor: select(t.name, {
-      light: t.atoms.border_contrast_low.borderColor,
-      dim: t.atoms.border_contrast_high.borderColor,
-      dark: t.atoms.border_contrast_high.borderColor,
-    }),
-    iconColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-    textColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-  },
-  info: {
-    backgroundColor: select(t.name, {
-      light: t.atoms.bg_contrast_25.backgroundColor,
-      dim: t.atoms.bg_contrast_100.backgroundColor,
-      dark: t.atoms.bg_contrast_100.backgroundColor,
-    }),
-    borderColor: select(t.name, {
-      light: t.atoms.border_contrast_low.borderColor,
-      dim: t.atoms.border_contrast_high.borderColor,
-      dark: t.atoms.border_contrast_high.borderColor,
-    }),
-    iconColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-    textColor: select(t.name, {
-      light: t.atoms.text_contrast_medium.color,
-      dim: t.atoms.text_contrast_medium.color,
-      dark: t.atoms.text_contrast_medium.color,
-    }),
-  },
-})
-
-export const getToastWebAnimationStyles = () => ({
-  entering: {
-    animation: 'toastFadeIn 0.3s ease-out forwards',
-  },
-  exiting: {
-    animation: 'toastFadeOut 0.2s ease-in forwards',
-  },
-})
-
-export const TOAST_WEB_KEYFRAMES = `
-  @keyframes toastFadeIn {
-    from {
-      opacity: 0;
-    }
-    to {
-      opacity: 1;
-    }
-  }
-
-  @keyframes toastFadeOut {
-    from {
-      opacity: 1;
-    }
-    to {
-      opacity: 0;
-    }
-  }
-`
diff --git a/src/view/com/util/Toast.tsx b/src/view/com/util/Toast.tsx
index 54ef7042d..37ec6acb5 100644
--- a/src/view/com/util/Toast.tsx
+++ b/src/view/com/util/Toast.tsx
@@ -1,234 +1,54 @@
-import {useEffect, useMemo, useRef, useState} from 'react'
-import {AccessibilityInfo, View} from 'react-native'
-import {
-  Gesture,
-  GestureDetector,
-  GestureHandlerRootView,
-} from 'react-native-gesture-handler'
-import Animated, {
-  FadeIn,
-  FadeOut,
-  runOnJS,
-  useAnimatedReaction,
-  useAnimatedStyle,
-  useSharedValue,
-  withDecay,
-  withSpring,
-} from 'react-native-reanimated'
-import RootSiblings from 'react-native-root-siblings'
-import {useSafeAreaInsets} from 'react-native-safe-area-context'
+import {toast} from '#/components/Toast'
+import {type ToastType} from '#/components/Toast/types'
 
-import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
-import {
-  convertLegacyToastType,
-  getToastTypeStyles,
-  type LegacyToastType,
-  TOAST_ANIMATION_CONFIG,
-  TOAST_TYPE_TO_ICON,
-  type ToastType,
-} from '#/view/com/util/Toast.style'
-import {atoms as a, useTheme} from '#/alf'
-import {Text} from '#/components/Typography'
-
-const TIMEOUT = 2e3
+/**
+ * @deprecated use {@link ToastType} and {@link toast} instead
+ */
+export type LegacyToastType =
+  | 'xmark'
+  | 'exclamation-circle'
+  | 'check'
+  | 'clipboard-check'
+  | 'circle-exclamation'
+
+export const convertLegacyToastType = (
+  type: ToastType | LegacyToastType,
+): ToastType => {
+  switch (type) {
+    // these ones are fine
+    case 'default':
+    case 'success':
+    case 'error':
+    case 'warning':
+    case 'info':
+      return type
+    // legacy ones need conversion
+    case 'xmark':
+      return 'error'
+    case 'exclamation-circle':
+      return 'warning'
+    case 'check':
+      return 'success'
+    case 'clipboard-check':
+      return 'success'
+    case 'circle-exclamation':
+      return 'warning'
+    default:
+      return 'default'
+  }
+}
 
-// Use type overloading to mark certain types as deprecated -sfn
-// https://stackoverflow.com/a/78325851/13325987
-export function show(message: string, type?: ToastType): void
 /**
- * @deprecated type is deprecated - use one of `'default' | 'success' | 'error' | 'warning' | 'info'`
+ * @deprecated use {@link toast} instead
  */
-export function show(message: string, type?: LegacyToastType): void
 export function show(
   message: string,
   type: ToastType | LegacyToastType = 'default',
 ): void {
-  if (process.env.NODE_ENV === 'test') {
-    return
-  }
-
-  AccessibilityInfo.announceForAccessibility(message)
-  const item = new RootSiblings(
-    (
-      <Toast
-        message={message}
-        type={convertLegacyToastType(type)}
-        destroy={() => item.destroy()}
-      />
-    ),
-  )
-}
-
-function Toast({
-  message,
-  type,
-  destroy,
-}: {
-  message: string
-  type: ToastType
-  destroy: () => void
-}) {
-  const t = useTheme()
-  const {top} = useSafeAreaInsets()
-  const isPanning = useSharedValue(false)
-  const dismissSwipeTranslateY = useSharedValue(0)
-  const [cardHeight, setCardHeight] = useState(0)
-
-  const toastStyles = getToastTypeStyles(t)
-  const colors = toastStyles[type]
-  const IconComponent = TOAST_TYPE_TO_ICON[type]
-
-  // for the exit animation to work on iOS the animated component
-  // must not be the root component
-  // so we need to wrap it in a view and unmount the toast ahead of time
-  const [alive, setAlive] = useState(true)
-
-  const hideAndDestroyImmediately = () => {
-    setAlive(false)
-    setTimeout(() => {
-      destroy()
-    }, 1e3)
-  }
-
-  const destroyTimeoutRef = useRef<ReturnType<typeof setTimeout>>()
-  const hideAndDestroyAfterTimeout = useNonReactiveCallback(() => {
-    clearTimeout(destroyTimeoutRef.current)
-    destroyTimeoutRef.current = setTimeout(hideAndDestroyImmediately, TIMEOUT)
+  const convertedType = convertLegacyToastType(type)
+  toast.show({
+    type: convertedType,
+    content: message,
+    a11yLabel: message,
   })
-  const pauseDestroy = useNonReactiveCallback(() => {
-    clearTimeout(destroyTimeoutRef.current)
-  })
-
-  useEffect(() => {
-    hideAndDestroyAfterTimeout()
-  }, [hideAndDestroyAfterTimeout])
-
-  const panGesture = useMemo(() => {
-    return Gesture.Pan()
-      .activeOffsetY([-10, 10])
-      .failOffsetX([-10, 10])
-      .maxPointers(1)
-      .onStart(() => {
-        'worklet'
-        if (!alive) return
-        isPanning.set(true)
-        runOnJS(pauseDestroy)()
-      })
-      .onUpdate(e => {
-        'worklet'
-        if (!alive) return
-        dismissSwipeTranslateY.value = e.translationY
-      })
-      .onEnd(e => {
-        'worklet'
-        if (!alive) return
-        runOnJS(hideAndDestroyAfterTimeout)()
-        isPanning.set(false)
-        if (e.velocityY < -100) {
-          if (dismissSwipeTranslateY.value === 0) {
-            // HACK: If the initial value is 0, withDecay() animation doesn't start.
-            // This is a bug in Reanimated, but for now we'll work around it like this.
-            dismissSwipeTranslateY.value = 1
-          }
-          dismissSwipeTranslateY.value = withDecay({
-            velocity: e.velocityY,
-            velocityFactor: Math.max(3500 / Math.abs(e.velocityY), 1),
-            deceleration: 1,
-          })
-        } else {
-          dismissSwipeTranslateY.value = withSpring(0, {
-            stiffness: 500,
-            damping: 50,
-          })
-        }
-      })
-  }, [
-    dismissSwipeTranslateY,
-    isPanning,
-    alive,
-    hideAndDestroyAfterTimeout,
-    pauseDestroy,
-  ])
-
-  const topOffset = top + 10
-
-  useAnimatedReaction(
-    () =>
-      !isPanning.get() &&
-      dismissSwipeTranslateY.get() < -topOffset - cardHeight,
-    (isSwipedAway, prevIsSwipedAway) => {
-      'worklet'
-      if (isSwipedAway && !prevIsSwipedAway) {
-        runOnJS(destroy)()
-      }
-    },
-  )
-
-  const animatedStyle = useAnimatedStyle(() => {
-    const translation = dismissSwipeTranslateY.get()
-    return {
-      transform: [
-        {
-          translateY: translation > 0 ? translation ** 0.7 : translation,
-        },
-      ],
-    }
-  })
-
-  return (
-    <GestureHandlerRootView
-      style={[a.absolute, {top: topOffset, left: 16, right: 16}]}
-      pointerEvents="box-none">
-      {alive && (
-        <Animated.View
-          entering={FadeIn.duration(TOAST_ANIMATION_CONFIG.duration)}
-          exiting={FadeOut.duration(TOAST_ANIMATION_CONFIG.duration * 0.7)}
-          onLayout={evt => setCardHeight(evt.nativeEvent.layout.height)}
-          accessibilityRole="alert"
-          accessible={true}
-          accessibilityLabel={message}
-          accessibilityHint=""
-          onAccessibilityEscape={hideAndDestroyImmediately}
-          style={[
-            a.flex_1,
-            {backgroundColor: colors.backgroundColor},
-            a.shadow_sm,
-            {borderColor: colors.borderColor, borderWidth: 1},
-            a.rounded_sm,
-            animatedStyle,
-          ]}>
-          <GestureDetector gesture={panGesture}>
-            <View style={[a.flex_1, a.px_md, a.py_lg, a.flex_row, a.gap_md]}>
-              <View
-                style={[
-                  a.flex_shrink_0,
-                  a.rounded_full,
-                  {width: 32, height: 32},
-                  a.align_center,
-                  a.justify_center,
-                  {
-                    backgroundColor: colors.backgroundColor,
-                  },
-                ]}>
-                <IconComponent fill={colors.iconColor} size="sm" />
-              </View>
-              <View
-                style={[
-                  a.h_full,
-                  a.justify_center,
-                  a.flex_1,
-                  a.justify_center,
-                ]}>
-                <Text
-                  style={[a.text_md, a.font_bold, {color: colors.textColor}]}
-                  emoji>
-                  {message}
-                </Text>
-              </View>
-            </View>
-          </GestureDetector>
-        </Animated.View>
-      )}
-    </GestureHandlerRootView>
-  )
 }
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
deleted file mode 100644
index 6b99b30bf..000000000
--- a/src/view/com/util/Toast.web.tsx
+++ /dev/null
@@ -1,180 +0,0 @@
-/*
- * Note: the dataSet properties are used to leverage custom CSS in public/index.html
- */
-
-import {useEffect, useState} from 'react'
-import {Pressable, StyleSheet, Text, View} from 'react-native'
-
-import {
-  convertLegacyToastType,
-  getToastTypeStyles,
-  getToastWebAnimationStyles,
-  type LegacyToastType,
-  TOAST_TYPE_TO_ICON,
-  TOAST_WEB_KEYFRAMES,
-  type ToastType,
-} from '#/view/com/util/Toast.style'
-import {atoms as a, useTheme} from '#/alf'
-
-const DURATION = 3500
-
-interface ActiveToast {
-  text: string
-  type: ToastType
-}
-type GlobalSetActiveToast = (_activeToast: ActiveToast | undefined) => void
-
-// globals
-// =
-let globalSetActiveToast: GlobalSetActiveToast | undefined
-let toastTimeout: NodeJS.Timeout | undefined
-
-// components
-// =
-type ToastContainerProps = {}
-export const ToastContainer: React.FC<ToastContainerProps> = ({}) => {
-  const [activeToast, setActiveToast] = useState<ActiveToast | undefined>()
-  const [isExiting, setIsExiting] = useState(false)
-
-  useEffect(() => {
-    globalSetActiveToast = (t: ActiveToast | undefined) => {
-      if (!t && activeToast) {
-        setIsExiting(true)
-        setTimeout(() => {
-          setActiveToast(t)
-          setIsExiting(false)
-        }, 200)
-      } else {
-        setActiveToast(t)
-        setIsExiting(false)
-      }
-    }
-  }, [activeToast])
-
-  useEffect(() => {
-    const styleId = 'toast-animations'
-    if (!document.getElementById(styleId)) {
-      const style = document.createElement('style')
-      style.id = styleId
-      style.textContent = TOAST_WEB_KEYFRAMES
-      document.head.appendChild(style)
-    }
-  }, [])
-
-  const t = useTheme()
-
-  const toastTypeStyles = getToastTypeStyles(t)
-  const toastStyles = activeToast
-    ? toastTypeStyles[activeToast.type]
-    : toastTypeStyles.default
-
-  const IconComponent = activeToast
-    ? TOAST_TYPE_TO_ICON[activeToast.type]
-    : TOAST_TYPE_TO_ICON.default
-
-  const animationStyles = getToastWebAnimationStyles()
-
-  return (
-    <>
-      {activeToast && (
-        <View
-          style={[
-            styles.container,
-            {
-              backgroundColor: toastStyles.backgroundColor,
-              borderColor: toastStyles.borderColor,
-              ...(isExiting
-                ? animationStyles.exiting
-                : animationStyles.entering),
-            },
-          ]}>
-          <View
-            style={[
-              styles.iconContainer,
-              {
-                backgroundColor: 'transparent',
-              },
-            ]}>
-            <IconComponent
-              fill={toastStyles.iconColor}
-              size="sm"
-              style={styles.icon}
-            />
-          </View>
-          <Text
-            style={[
-              styles.text,
-              a.text_sm,
-              a.font_bold,
-              {color: toastStyles.textColor},
-            ]}>
-            {activeToast.text}
-          </Text>
-          <Pressable
-            style={styles.dismissBackdrop}
-            accessibilityLabel="Dismiss"
-            accessibilityHint=""
-            onPress={() => {
-              setActiveToast(undefined)
-            }}
-          />
-        </View>
-      )}
-    </>
-  )
-}
-
-// methods
-// =
-
-export function show(
-  text: string,
-  type: ToastType | LegacyToastType = 'default',
-) {
-  if (toastTimeout) {
-    clearTimeout(toastTimeout)
-  }
-
-  globalSetActiveToast?.({text, type: convertLegacyToastType(type)})
-  toastTimeout = setTimeout(() => {
-    globalSetActiveToast?.(undefined)
-  }, DURATION)
-}
-
-const styles = StyleSheet.create({
-  container: {
-    // @ts-ignore web only
-    position: 'fixed',
-    left: 20,
-    bottom: 20,
-    // @ts-ignore web only
-    width: 'calc(100% - 40px)',
-    maxWidth: 380,
-    padding: 20,
-    flexDirection: 'row',
-    alignItems: 'center',
-    borderRadius: 10,
-    borderWidth: 1,
-  },
-  dismissBackdrop: {
-    position: 'absolute',
-    top: 0,
-    left: 0,
-    bottom: 0,
-    right: 0,
-  },
-  iconContainer: {
-    width: 32,
-    height: 32,
-    borderRadius: 16,
-    alignItems: 'center',
-    justifyContent: 'center',
-    flexShrink: 0,
-  },
-  icon: {
-    flexShrink: 0,
-  },
-  text: {
-    marginLeft: 10,
-  },
-})
diff --git a/src/view/screens/Storybook/Toasts.tsx b/src/view/screens/Storybook/Toasts.tsx
index 4c17f1c33..8fc6f095f 100644
--- a/src/view/screens/Storybook/Toasts.tsx
+++ b/src/view/screens/Storybook/Toasts.tsx
@@ -1,65 +1,11 @@
 import {Pressable, View} from 'react-native'
 
-import * as Toast from '#/view/com/util/Toast'
-import {
-  getToastTypeStyles,
-  TOAST_TYPE_TO_ICON,
-  type ToastType,
-} from '#/view/com/util/Toast.style'
-import {atoms as a, useTheme} from '#/alf'
-import {H1, Text} from '#/components/Typography'
-
-function ToastPreview({message, type}: {message: string; type: ToastType}) {
-  const t = useTheme()
-  const toastStyles = getToastTypeStyles(t)
-  const colors = toastStyles[type as keyof typeof toastStyles]
-  const IconComponent =
-    TOAST_TYPE_TO_ICON[type as keyof typeof TOAST_TYPE_TO_ICON]
-
-  return (
-    <Pressable
-      accessibilityRole="button"
-      onPress={() => Toast.show(message, type)}
-      style={[
-        {backgroundColor: colors.backgroundColor},
-        a.shadow_sm,
-        {borderColor: colors.borderColor},
-        a.rounded_sm,
-        a.border,
-        a.px_sm,
-        a.py_sm,
-        a.flex_row,
-        a.gap_sm,
-        a.align_center,
-      ]}>
-      <View
-        style={[
-          a.flex_shrink_0,
-          a.rounded_full,
-          {width: 24, height: 24},
-          a.align_center,
-          a.justify_center,
-          {
-            backgroundColor: colors.backgroundColor,
-          },
-        ]}>
-        <IconComponent fill={colors.iconColor} size="xs" />
-      </View>
-      <View style={[a.flex_1]}>
-        <Text
-          style={[
-            a.text_sm,
-            a.font_bold,
-            a.leading_snug,
-            {color: colors.textColor},
-          ]}
-          emoji>
-          {message}
-        </Text>
-      </View>
-    </Pressable>
-  )
-}
+import {show as deprecatedShow} from '#/view/com/util/Toast'
+import {atoms as a} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {toast} from '#/components/Toast'
+import {Toast} from '#/components/Toast/Toast'
+import {H1} from '#/components/Typography'
 
 export function Toasts() {
   return (
@@ -67,35 +13,103 @@ export function Toasts() {
       <H1>Toast Examples</H1>
 
       <View style={[a.gap_md]}>
-        <View style={[a.gap_xs]}>
-          <ToastPreview message="Default Toast" type="default" />
-        </View>
-
-        <View style={[a.gap_xs]}>
-          <ToastPreview
-            message="Operation completed successfully!"
-            type="success"
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'default',
+              content: 'Default toast',
+              a11yLabel: 'Default toast',
+            })
+          }>
+          <Toast content="Default toast" type="default" />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'default',
+              content: 'Default toast, 6 seconds',
+              a11yLabel: 'Default toast, 6 seconds',
+              duration: 6e3,
+            })
+          }>
+          <Toast content="Default toast, 6 seconds" type="default" />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'default',
+              content:
+                'This is a longer message to test how the toast handles multiple lines of text content.',
+              a11yLabel:
+                'This is a longer message to test how the toast handles multiple lines of text content.',
+            })
+          }>
+          <Toast
+            content="This is a longer message to test how the toast handles multiple lines of text content."
+            type="default"
           />
-        </View>
-
-        <View style={[a.gap_xs]}>
-          <ToastPreview message="Something went wrong!" type="error" />
-        </View>
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'success',
+              content: 'Success toast',
+              a11yLabel: 'Success toast',
+            })
+          }>
+          <Toast content="Success toast" type="success" />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'info',
+              content: 'Info toast',
+              a11yLabel: 'Info toast',
+            })
+          }>
+          <Toast content="Info" type="info" />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'warning',
+              content: 'Warning toast',
+              a11yLabel: 'Warning toast',
+            })
+          }>
+          <Toast content="Warning" type="warning" />
+        </Pressable>
+        <Pressable
+          accessibilityRole="button"
+          onPress={() =>
+            toast.show({
+              type: 'error',
+              content: 'Error toast',
+              a11yLabel: 'Error toast',
+            })
+          }>
+          <Toast content="Error" type="error" />
+        </Pressable>
 
-        <View style={[a.gap_xs]}>
-          <ToastPreview message="Please check your input" type="warning" />
-        </View>
-
-        <View style={[a.gap_xs]}>
-          <ToastPreview message="Here's some helpful information" type="info" />
-        </View>
-
-        <View style={[a.gap_xs]}>
-          <ToastPreview
-            message="This is a longer message to test how the toast handles multiple lines of text content."
-            type="info"
-          />
-        </View>
+        <Button
+          label="Deprecated toast example"
+          onPress={() =>
+            deprecatedShow(
+              'This is a deprecated toast example',
+              'exclamation-circle',
+            )
+          }
+          size="large"
+          variant="solid"
+          color="secondary">
+          <ButtonText>Deprecated toast example</ButtonText>
+        </Button>
       </View>
     </View>
   )
diff --git a/src/view/screens/Storybook/index.tsx b/src/view/screens/Storybook/index.tsx
index afcc1c4e7..40ef79cca 100644
--- a/src/view/screens/Storybook/index.tsx
+++ b/src/view/screens/Storybook/index.tsx
@@ -91,6 +91,8 @@ function StorybookInner() {
               </Button>
             </View>
 
+            <Toasts />
+
             <Button
               variant="solid"
               color="primary"
@@ -123,7 +125,6 @@ function StorybookInner() {
             <Breakpoints />
             <Dialogs />
             <Admonitions />
-            <Toasts />
             <Settings />
 
             <Button