diff --git a/assets/images/welcome-modal-bg.jpg b/assets/images/welcome-modal-bg.jpg
new file mode 100644
index 0000000000..99b6c9d646
Binary files /dev/null and b/assets/images/welcome-modal-bg.jpg differ
diff --git a/src/alf/atoms.ts b/src/alf/atoms.ts
index ae88f457dc..dc5d9f59c3 100644
--- a/src/alf/atoms.ts
+++ b/src/alf/atoms.ts
@@ -1063,6 +1063,7 @@ export const atoms = {
}),
fade_out: web({
animation: 'fadeOut ease-out 0.15s',
+ animationFillMode: 'forwards',
}),
zoom_in: web({
animation: 'zoomIn ease-out 0.1s',
diff --git a/src/components/WelcomeModal.tsx b/src/components/WelcomeModal.tsx
new file mode 100644
index 0000000000..7c9ecd84de
--- /dev/null
+++ b/src/components/WelcomeModal.tsx
@@ -0,0 +1,247 @@
+import {useEffect, useState} from 'react'
+import {Pressable, View} from 'react-native'
+import {ImageBackground} from 'expo-image'
+import {msg, Trans} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+import {FocusGuards, FocusScope} from 'radix-ui/internal'
+
+import {logger} from '#/logger'
+import {useLoggedOutViewControls} from '#/state/shell/logged-out'
+import {Logo} from '#/view/icons/Logo'
+import {atoms as a, flatten, useBreakpoints, web} from '#/alf'
+import {Button, ButtonText} from '#/components/Button'
+import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times'
+import {Text} from '#/components/Typography'
+
+const welcomeModalBg = require('../../assets/images/welcome-modal-bg.jpg')
+
+interface WelcomeModalProps {
+ control: {
+ isOpen: boolean
+ open: () => void
+ close: () => void
+ }
+}
+
+export function WelcomeModal({control}: WelcomeModalProps) {
+ const {_} = useLingui()
+ const {requestSwitchToAccount} = useLoggedOutViewControls()
+ const {gtMobile} = useBreakpoints()
+ const [isExiting, setIsExiting] = useState(false)
+ const [signInLinkHovered, setSignInLinkHovered] = useState(false)
+
+ const fadeOutAndClose = (callback?: () => void) => {
+ setIsExiting(true)
+ setTimeout(() => {
+ control.close()
+ if (callback) callback()
+ }, 150)
+ }
+
+ useEffect(() => {
+ if (control.isOpen) {
+ logger.metric('welcomeModal:presented', {})
+ }
+ }, [control.isOpen])
+
+ const onPressCreateAccount = () => {
+ logger.metric('welcomeModal:signupClicked', {})
+ control.close()
+ requestSwitchToAccount({requestedAccount: 'new'})
+ }
+
+ const onPressExplore = () => {
+ logger.metric('welcomeModal:exploreClicked', {})
+ fadeOutAndClose()
+ }
+
+ const onPressSignIn = () => {
+ logger.metric('welcomeModal:signinClicked', {})
+ control.close()
+ requestSwitchToAccount({requestedAccount: 'existing'})
+ }
+
+ FocusGuards.useFocusGuards()
+
+ return (
+
+
+
+
+
+
+
+
+
+ Bluesky
+
+
+
+
+
+ Real people.
+ {'\n'}
+ Real conversations.
+ {'\n'}
+ Social media you control.
+
+
+
+
+
+
+
+
+
+ Already have an account?{' '}
+ setSignInLinkHovered(true)}
+ onPointerLeave={() => setSignInLinkHovered(false)}
+ accessibilityRole="button"
+ accessibilityLabel={_(msg`Sign in`)}
+ accessibilityHint="">
+
+ Sign in
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/hooks/useWelcomeModal.native.ts b/src/components/hooks/useWelcomeModal.native.ts
new file mode 100644
index 0000000000..f5bc1aa4ed
--- /dev/null
+++ b/src/components/hooks/useWelcomeModal.native.ts
@@ -0,0 +1,3 @@
+export function useWelcomeModal() {
+ throw new Error('useWelcomeModal is web only')
+}
diff --git a/src/components/hooks/useWelcomeModal.ts b/src/components/hooks/useWelcomeModal.ts
new file mode 100644
index 0000000000..7183f361e1
--- /dev/null
+++ b/src/components/hooks/useWelcomeModal.ts
@@ -0,0 +1,43 @@
+import {useEffect, useState} from 'react'
+
+import {isWeb} from '#/platform/detection'
+import {useSession} from '#/state/session'
+
+export function useWelcomeModal() {
+ const {hasSession} = useSession()
+ const [isOpen, setIsOpen] = useState(false)
+
+ const open = () => setIsOpen(true)
+ const close = () => {
+ setIsOpen(false)
+ // Mark that user has actively closed the modal, don't show again this session
+ if (typeof window !== 'undefined') {
+ sessionStorage.setItem('welcomeModalClosed', 'true')
+ }
+ }
+
+ useEffect(() => {
+ // Only show modal if:
+ // 1. User is not logged in
+ // 2. We're on the web (this is a web-only feature)
+ // 3. We're on the homepage (path is '/' or '/home')
+ // 4. User hasn't actively closed the modal in this session
+ if (isWeb && !hasSession && typeof window !== 'undefined') {
+ const currentPath = window.location.pathname
+ const isHomePage = currentPath === '/'
+ const hasUserClosedModal =
+ sessionStorage.getItem('welcomeModalClosed') === 'true'
+
+ if (isHomePage && !hasUserClosedModal) {
+ // Small delay to ensure the page has loaded
+ const timer = setTimeout(() => {
+ open()
+ }, 1000)
+
+ return () => clearTimeout(timer)
+ }
+ }
+ }, [hasSession])
+
+ return {isOpen, open, close}
+}
diff --git a/src/lib/hooks/useWebMediaQueries.tsx b/src/lib/hooks/useWebMediaQueries.tsx
index fa9d6ffa64..7778383a42 100644
--- a/src/lib/hooks/useWebMediaQueries.tsx
+++ b/src/lib/hooks/useWebMediaQueries.tsx
@@ -2,6 +2,9 @@ import {useMediaQuery} from 'react-responsive'
import {isNative} from '#/platform/detection'
+/**
+ * @deprecated use `useBreakpoints` from `#/alf` instead
+ */
export function useWebMediaQueries() {
const isDesktop = useMediaQuery({minWidth: 1300})
const isTablet = useMediaQuery({minWidth: 800, maxWidth: 1300 - 1})
diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts
index 391314162c..b8e2e9a3c2 100644
--- a/src/lib/statsig/gates.ts
+++ b/src/lib/statsig/gates.ts
@@ -15,3 +15,4 @@ export type Gate =
| 'remove_show_latest_button'
| 'test_gate_1'
| 'test_gate_2'
+ | 'welcome_modal'
diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts
index 1cb4eb9d36..79d7702b33 100644
--- a/src/logger/metrics.ts
+++ b/src/logger/metrics.ts
@@ -48,6 +48,11 @@ export type MetricEvents = {
// Screen events
'splash:signInPressed': {}
'splash:createAccountPressed': {}
+ 'welcomeModal:signupClicked': {}
+ 'welcomeModal:exploreClicked': {}
+ 'welcomeModal:signinClicked': {}
+ 'welcomeModal:dismissed': {}
+ 'welcomeModal:presented': {}
'signup:nextPressed': {
activeStep: number
phoneVerificationRequired?: boolean
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index f942ab49eb..bb09b5f62d 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -8,6 +8,7 @@ import {RemoveScrollBar} from 'react-remove-scroll-bar'
import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type NavigationProp} from '#/lib/routes/types'
+import {useGate} from '#/lib/statsig/statsig'
import {useGeolocation} from '#/state/geolocation'
import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
@@ -22,11 +23,13 @@ import {EmailDialog} from '#/components/dialogs/EmailDialog'
import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
import {SigninDialog} from '#/components/dialogs/Signin'
+import {useWelcomeModal} from '#/components/hooks/useWelcomeModal'
import {
Outlet as PolicyUpdateOverlayPortalOutlet,
usePolicyUpdateContext,
} from '#/components/PolicyUpdateOverlay'
import {Outlet as PortalOutlet} from '#/components/Portal'
+import {WelcomeModal} from '#/components/WelcomeModal'
import {FlatNavigator, RoutesContainer} from '#/Navigation'
import {Composer} from './Composer.web'
import {DrawerContent} from './Drawer'
@@ -42,6 +45,8 @@ function ShellInner() {
const showDrawer = !isDesktop && isDrawerOpen
const [showDrawerDelayedExit, setShowDrawerDelayedExit] = useState(showDrawer)
const {state: policyUpdateState} = usePolicyUpdateContext()
+ const welcomeModalControl = useWelcomeModal()
+ const gate = useGate()
useLayoutEffect(() => {
if (showDrawer !== showDrawerDelayedExit) {
@@ -80,6 +85,11 @@ function ShellInner() {
+ {/* Show welcome modal if the gate is enabled */}
+ {welcomeModalControl.isOpen && gate('welcome_modal') && (
+
+ )}
+
{/* Until policy update has been completed by the user, don't render anything that is portaled */}
{policyUpdateState.completed && (
<>