diff --git a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx
index 56722ec6f..7dfbbc4e8 100644
--- a/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx
+++ b/apps/mobile/src/modules/screen/TimelineSelectorProvider.tsx
@@ -1,8 +1,17 @@
-import { useMemo } from "react"
+import type { FC } from "react"
+import { useEffect, useMemo, useRef, useState } from "react"
import { View } from "react-native"
+import Animated, {
+ runOnJS,
+ useAnimatedStyle,
+ useSharedValue,
+ withSpring,
+} from "react-native-reanimated"
+import { FakeNativeHeaderTitle } from "@/src/components/layouts/header/FakeNativeHeaderTitle"
import { DefaultHeaderBackButton } from "@/src/components/layouts/header/NavigationHeader"
import { NavigationBlurEffectHeader } from "@/src/components/layouts/views/SafeNavigationScrollView"
+import { gentleSpringPreset } from "@/src/constants/spring"
import { TIMELINE_VIEW_SELECTOR_HEIGHT } from "@/src/constants/ui"
import {
ActionGroup,
@@ -27,7 +36,7 @@ export function TimelineHeader({ feedId }: { feedId?: string }) {
return (
}
isLoading={(isFeed || isTimeline) && isFetching}
headerLeft={useMemo(
() =>
@@ -52,3 +61,81 @@ export function TimelineHeader({ feedId }: { feedId?: string }) {
/>
)
}
+
+const AnimatedTitle: FC<{
+ title: string | undefined
+}> = ({ title }) => {
+ // Track titles and animation state
+ const [displayedTitle, setDisplayedTitle] = useState(title)
+ const [oldTitle, setOldTitle] = useState()
+ const [isAnimating, setIsAnimating] = useState(false)
+ const timeoutRef = useRef(undefined)
+
+ // Animation values - only opacity for cross-fade effect
+ const newTitleOpacity = useSharedValue(1)
+ const oldTitleOpacity = useSharedValue(0)
+
+ // Handle title changes with cross-fade animation
+ useEffect(() => {
+ if (title !== displayedTitle && title !== undefined && !isAnimating) {
+ setIsAnimating(true)
+ setOldTitle(displayedTitle)
+
+ // Start cross-fade animation
+ oldTitleOpacity.value = 1
+ newTitleOpacity.value = 0
+
+ // Fade out old title and fade in new title
+ oldTitleOpacity.value = withSpring(0, gentleSpringPreset)
+
+ // Update displayed title and fade in
+ timeoutRef.current = setTimeout(() => {
+ setDisplayedTitle(title)
+ newTitleOpacity.value = withSpring(1, gentleSpringPreset, () => {
+ runOnJS(setIsAnimating)(false)
+ runOnJS(setOldTitle)(void 0)
+ })
+ }, 150)
+ }
+ }, [title, displayedTitle, isAnimating, newTitleOpacity, oldTitleOpacity])
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ }
+ }
+ }, [])
+
+ // Initialize displayed title
+ useEffect(() => {
+ if (displayedTitle === undefined && title !== undefined) {
+ setDisplayedTitle(title)
+ }
+ }, [title, displayedTitle])
+
+ const newTitleStyle = useAnimatedStyle(() => ({
+ opacity: newTitleOpacity.value,
+ }))
+
+ const oldTitleStyle = useAnimatedStyle(() => ({
+ opacity: oldTitleOpacity.value,
+ }))
+
+ return (
+
+ {/* Current/New Title */}
+
+ {displayedTitle}
+
+
+ {/* Old Title (during transition) */}
+ {oldTitle && isAnimating && (
+
+ {oldTitle}
+
+ )}
+
+ )
+}