about summary refs log tree commit diff
diff options
context:
space:
mode:
authordan <dan.abramov@gmail.com>2024-01-22 22:46:32 +0000
committerGitHub <noreply@github.com>2024-01-22 14:46:32 -0800
commitf015229acfef48084f9f9bbafd1d7fb787cbe00e (patch)
tree17c7f33b007d57bd1f5065029fb8701a5763ae9d
parentcd02922b031f38e90bdfa2e63091535ab28d34de (diff)
downloadvoidsky-f015229acfef48084f9f9bbafd1d7fb787cbe00e.tar.zst
New Web Layout (#2126)
* Rip out virtualization on the web

* Screw around with layout

* onEndReached

* scrollToOffset

* Fix background

* onScroll

* Shell bars

* More scroll

* Fixes

* position: sticky

* Clean up 1

* Clean up 2

* Undo PagerWithHeader changes and fork it

* Trim down both versions

* Cleanup 3

* Memoize, lint

* Don't scroll away modal or lightbox

* Add content-visibility for rows

* Fix composer

* Fix types

* Fix borked scroll animation

* Fixes to layout

* More FlatList parity

* Layout fixes

* Fix more layout

* More layout

* More layouts

* Fix profile layout

* Remove onScroll

* Display: none inactive pages

* Add an intermediate List component

* Fix type

* Add onScrolledDownChange

* Port pager to use onScrolledDownChange

* Fix on mobile

* Don't pass down onScroll (replacement TBD)

* Remove resetMainScroll

* Replace onMainScroll with MainScrollProvider

* Hook ScrollProvider to pager

* Fix the remaining special case

* Optimize a bit

* Enforce that onScroll cannot be passed

* Keep value updated even if no handler

* Also memo it

* Move the fork to List.web

* Add scroll handler

* Consolidate List props a bit

* More stuff

* Rm unused

* Simplify

* Make isScrolledDown work

* Oops

* Fixes

* Hook up context scroll handlers

* Scroll restore for tabs

* Route scroll restoration POC

* Fix some issues with restoration

* Remove bad idea

* Fix pager scroll restoration

* Undo accidental locale changes

* onContentSizeChange

* Scroll to post

* Better positioning

* Layout fixes

* Factor out navigation stuff

* Cleanup

* Oops

* Cleanup

* Fixes and types

* Naming etc

* Fix crash

* Match FL semantics

* Snap the header scroll on the web

* Add body scroll lock

* Scroll to top on search

* Fix types

* Typos

* Fix Safari overflow

* Fix search positioning

* Add border

* Patch react navigation

* Revert "Patch react navigation"

This reverts commit 62516ed9c20410d166e1582b43b656c819495ddc.

* fixes

* scroll

* scrollbar

* cleanup unrelated

* undo unrel

* flatter

* Fix css

* twk
-rw-r--r--bskyweb/templates/base.html2
-rw-r--r--package.json1
-rw-r--r--src/Navigation.tsx5
-rw-r--r--src/lib/batchedUpdates.web.ts1
-rw-r--r--src/lib/hooks/useWebBodyScrollLock.ts28
-rw-r--r--src/lib/hooks/useWebScrollRestoration.native.ts3
-rw-r--r--src/lib/hooks/useWebScrollRestoration.ts52
-rw-r--r--src/lib/styles.ts4
-rw-r--r--src/view/com/composer/text-input/web/EmojiPicker.web.tsx3
-rw-r--r--src/view/com/feeds/FeedPage.tsx11
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx20
-rw-r--r--src/view/com/modals/Modal.web.tsx5
-rw-r--r--src/view/com/pager/FeedsTabBar.web.tsx11
-rw-r--r--src/view/com/pager/FeedsTabBarMobile.tsx3
-rw-r--r--src/view/com/pager/Pager.tsx1
-rw-r--r--src/view/com/pager/Pager.web.tsx51
-rw-r--r--src/view/com/pager/PagerWithHeader.tsx15
-rw-r--r--src/view/com/pager/PagerWithHeader.web.tsx194
-rw-r--r--src/view/com/post-thread/PostThread.tsx38
-rw-r--r--src/view/com/util/List.tsx6
-rw-r--r--src/view/com/util/List.web.tsx341
-rw-r--r--src/view/com/util/MainScrollProvider.tsx37
-rw-r--r--src/view/com/util/SimpleViewHeader.tsx16
-rw-r--r--src/view/com/util/Toast.web.tsx3
-rw-r--r--src/view/com/util/fab/FABInner.tsx4
-rw-r--r--src/view/screens/PostThread.tsx4
-rw-r--r--src/view/screens/Search/Search.tsx35
-rw-r--r--src/view/shell/Composer.web.tsx8
-rw-r--r--src/view/shell/bottom-bar/BottomBarStyles.tsx4
-rw-r--r--src/view/shell/bottom-bar/BottomBarWeb.tsx1
-rw-r--r--src/view/shell/desktop/LeftNav.tsx5
-rw-r--r--src/view/shell/desktop/RightNav.tsx5
-rw-r--r--src/view/shell/index.web.tsx20
-rw-r--r--web/index.html2
-rw-r--r--yarn.lock7
35 files changed, 849 insertions, 97 deletions
diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html
index 50dbaa24b..228c3d894 100644
--- a/bskyweb/templates/base.html
+++ b/bskyweb/templates/base.html
@@ -33,10 +33,10 @@
     }
 
     html {
-      scroll-behavior: smooth;
       /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
       -webkit-text-size-adjust: 100%;
       height: calc(100% + env(safe-area-inset-top));
+      scrollbar-gutter: stable both-edges;
     }
 
     /* Remove autofill styles on Webkit */
diff --git a/package.json b/package.json
index 40e2a19f2..17677fb97 100644
--- a/package.json
+++ b/package.json
@@ -206,6 +206,7 @@
     "@types/lodash.shuffle": "^4.2.7",
     "@types/psl": "^1.1.1",
     "@types/react-avatar-editor": "^13.0.0",
+    "@types/react-dom": "^18.2.18",
     "@types/react-responsive": "^8.0.5",
     "@types/react-test-renderer": "^17.0.1",
     "@typescript-eslint/eslint-plugin": "^5.48.2",
diff --git a/src/Navigation.tsx b/src/Navigation.tsx
index 3689cfc90..35d8dff74 100644
--- a/src/Navigation.tsx
+++ b/src/Navigation.tsx
@@ -39,6 +39,7 @@ import {
   setEmailConfirmationRequested,
 } from './state/shell/reminders'
 import {init as initAnalytics} from './lib/analytics/analytics'
+import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration'
 
 import {HomeScreen} from './view/screens/Home'
 import {SearchScreen} from './view/screens/Search'
@@ -413,10 +414,12 @@ function MyProfileTabNavigator() {
 const FlatNavigator = () => {
   const pal = usePalette('default')
   const numUnread = useUnreadNotifications()
-
+  const screenListeners = useWebScrollRestoration()
   const title = (page: MessageDescriptor) => bskyTitle(i18n._(page), numUnread)
+
   return (
     <Flat.Navigator
+      screenListeners={screenListeners}
       screenOptions={{
         gestureEnabled: true,
         fullScreenGestureEnabled: true,
diff --git a/src/lib/batchedUpdates.web.ts b/src/lib/batchedUpdates.web.ts
index 03147ed67..ba82549b5 100644
--- a/src/lib/batchedUpdates.web.ts
+++ b/src/lib/batchedUpdates.web.ts
@@ -1,2 +1 @@
-// @ts-ignore
 export {unstable_batchedUpdates as batchedUpdates} from 'react-dom'
diff --git a/src/lib/hooks/useWebBodyScrollLock.ts b/src/lib/hooks/useWebBodyScrollLock.ts
new file mode 100644
index 000000000..585f193f1
--- /dev/null
+++ b/src/lib/hooks/useWebBodyScrollLock.ts
@@ -0,0 +1,28 @@
+import {useEffect} from 'react'
+import {isWeb} from '#/platform/detection'
+
+let refCount = 0
+
+function incrementRefCount() {
+  if (refCount === 0) {
+    document.body.style.overflow = 'hidden'
+  }
+  refCount++
+}
+
+function decrementRefCount() {
+  refCount--
+  if (refCount === 0) {
+    document.body.style.overflow = ''
+  }
+}
+
+export function useWebBodyScrollLock(isLockActive: boolean) {
+  useEffect(() => {
+    if (!isWeb || !isLockActive) {
+      return
+    }
+    incrementRefCount()
+    return () => decrementRefCount()
+  })
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.native.ts b/src/lib/hooks/useWebScrollRestoration.native.ts
new file mode 100644
index 000000000..c7d96607f
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.native.ts
@@ -0,0 +1,3 @@
+export function useWebScrollRestoration() {
+  return undefined
+}
diff --git a/src/lib/hooks/useWebScrollRestoration.ts b/src/lib/hooks/useWebScrollRestoration.ts
new file mode 100644
index 000000000..f68fbf0f2
--- /dev/null
+++ b/src/lib/hooks/useWebScrollRestoration.ts
@@ -0,0 +1,52 @@
+import {useMemo, useState, useEffect} from 'react'
+import {EventArg, useNavigation} from '@react-navigation/core'
+
+if ('scrollRestoration' in history) {
+  // Tell the brower not to mess with the scroll.
+  // We're doing that manually below.
+  history.scrollRestoration = 'manual'
+}
+
+function createInitialScrollState() {
+  return {
+    scrollYs: new Map(),
+    focusedKey: null as string | null,
+  }
+}
+
+export function useWebScrollRestoration() {
+  const [state] = useState(createInitialScrollState)
+  const navigation = useNavigation()
+
+  useEffect(() => {
+    function onDispatch() {
+      if (state.focusedKey) {
+        // Remember where we were for later.
+        state.scrollYs.set(state.focusedKey, window.scrollY)
+        // TODO: Strictly speaking, this is a leak. We never clean up.
+        // This is because I'm not sure when it's appropriate to clean it up.
+        // It doesn't seem like popstate is enough because it can still Forward-Back again.
+        // Maybe we should use sessionStorage. Or check what Next.js is doing?
+      }
+    }
+    // We want to intercept any push/pop/replace *before* the re-render.
+    // There is no official way to do this yet, but this works okay for now.
+    // https://twitter.com/satya164/status/1737301243519725803
+    navigation.addListener('__unsafe_action__' as any, onDispatch)
+    return () => {
+      navigation.removeListener('__unsafe_action__' as any, onDispatch)
+    }
+  }, [state, navigation])
+
+  const screenListeners = useMemo(
+    () => ({
+      focus(e: EventArg<'focus', boolean | undefined, unknown>) {
+        const scrollY = state.scrollYs.get(e.target) ?? 0
+        window.scrollTo(0, scrollY)
+        state.focusedKey = e.target ?? null
+      },
+    }),
+    [state],
+  )
+  return screenListeners
+}
diff --git a/src/lib/styles.ts b/src/lib/styles.ts
index 5a10fea86..df9b49260 100644
--- a/src/lib/styles.ts
+++ b/src/lib/styles.ts
@@ -1,6 +1,6 @@
 import {Dimensions, StyleProp, StyleSheet, TextStyle} from 'react-native'
 import {Theme, TypographyVariant} from './ThemeContext'
-import {isMobileWeb} from 'platform/detection'
+import {isWeb} from 'platform/detection'
 
 // 1 is lightest, 2 is light, 3 is mid, 4 is dark, 5 is darkest
 export const colors = {
@@ -175,7 +175,7 @@ export const s = StyleSheet.create({
   // dimensions
   w100pct: {width: '100%'},
   h100pct: {height: '100%'},
-  hContentRegion: isMobileWeb ? {flex: 1} : {height: '100%'},
+  hContentRegion: isWeb ? {minHeight: '100%'} : {height: '100%'},
   window: {
     width: Dimensions.get('window').width,
     height: Dimensions.get('window').height,
diff --git a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
index 6d16403ff..149362116 100644
--- a/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
+++ b/src/view/com/composer/text-input/web/EmojiPicker.web.tsx
@@ -121,7 +121,8 @@ export function EmojiPicker({state, close}: IProps) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore web ony
+    position: 'fixed',
     top: 0,
     left: 0,
     right: 0,
diff --git a/src/view/com/feeds/FeedPage.tsx b/src/view/com/feeds/FeedPage.tsx
index 49f280981..9595e77e5 100644
--- a/src/view/com/feeds/FeedPage.tsx
+++ b/src/view/com/feeds/FeedPage.tsx
@@ -210,18 +210,9 @@ function useHeaderOffset() {
   const {isDesktop, isTablet} = useWebMediaQueries()
   const {fontScale} = useWindowDimensions()
   const {hasSession} = useSession()
-
-  if (isDesktop) {
+  if (isDesktop || isTablet) {
     return 0
   }
-  if (isTablet) {
-    if (hasSession) {
-      return 50
-    } else {
-      return 0
-    }
-  }
-
   if (hasSession) {
     const navBarPad = 16
     const navBarText = 21 * fontScale
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index a258d25ab..fb97c30a4 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -1,13 +1,17 @@
 import React, {useCallback, useEffect, useState} from 'react'
 import {
   Image,
+  ImageStyle,
   TouchableOpacity,
   TouchableWithoutFeedback,
   StyleSheet,
   View,
   Pressable,
 } from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+import {
+  FontAwesomeIcon,
+  FontAwesomeIconStyle,
+} from '@fortawesome/react-native-fontawesome'
 import {colors, s} from 'lib/styles'
 import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
 import {Text} from '../util/text/Text'
@@ -19,6 +23,7 @@ import {
   ImagesLightbox,
   ProfileImageLightbox,
 } from '#/state/lightbox'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 
 interface Img {
   uri: string
@@ -28,8 +33,10 @@ interface Img {
 export function Lightbox() {
   const {activeLightbox} = useLightbox()
   const {closeLightbox} = useLightboxControls()
+  const isActive = !!activeLightbox
+  useWebBodyScrollLock(isActive)
 
-  if (!activeLightbox) {
+  if (!isActive) {
     return null
   }
 
@@ -116,7 +123,7 @@ function LightboxInner({
           <Image
             accessibilityIgnoresInvertColors
             source={imgs[index]}
-            style={styles.image}
+            style={styles.image as ImageStyle}
             accessibilityLabel={imgs[index].alt}
             accessibilityHint=""
           />
@@ -129,7 +136,7 @@ function LightboxInner({
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-left"
-                style={styles.icon}
+                style={styles.icon as FontAwesomeIconStyle}
                 size={40}
               />
             </TouchableOpacity>
@@ -143,7 +150,7 @@ function LightboxInner({
               accessibilityHint="">
               <FontAwesomeIcon
                 icon="angle-right"
-                style={styles.icon}
+                style={styles.icon as FontAwesomeIconStyle}
                 size={40}
               />
             </TouchableOpacity>
@@ -178,7 +185,8 @@ function LightboxInner({
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index e11e76fcd..d79663746 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -3,6 +3,7 @@ import {TouchableWithoutFeedback, StyleSheet, View} from 'react-native'
 import Animated, {FadeIn, FadeOut} from 'react-native-reanimated'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 
 import {useModals, useModalControls} from '#/state/modals'
 import type {Modal as ModalIface} from '#/state/modals'
@@ -38,6 +39,7 @@ import * as EmbedConsentModal from './EmbedConsent'
 
 export function ModalsContainer() {
   const {isModalActive, activeModals} = useModals()
+  useWebBodyScrollLock(isModalActive)
 
   if (!isModalActive) {
     return null
@@ -166,7 +168,8 @@ function Modal({modal}: {modal: ModalIface}) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/com/pager/FeedsTabBar.web.tsx b/src/view/com/pager/FeedsTabBar.web.tsx
index 57c83f17c..385da5544 100644
--- a/src/view/com/pager/FeedsTabBar.web.tsx
+++ b/src/view/com/pager/FeedsTabBar.web.tsx
@@ -117,7 +117,7 @@ function FeedsTabBarTablet(
   return (
     // @ts-ignore the type signature for transform wrong here, translateX and translateY need to be in separate objects -prf
     <Animated.View
-      style={[pal.view, styles.tabBar, headerMinimalShellTransform]}
+      style={[pal.view, pal.border, styles.tabBar, headerMinimalShellTransform]}
       onLayout={e => {
         headerHeight.value = e.nativeEvent.layout.height
       }}>
@@ -134,13 +134,16 @@ function FeedsTabBarTablet(
 
 const styles = StyleSheet.create({
   tabBar: {
-    position: 'absolute',
+    // @ts-ignore Web only
+    position: 'sticky',
     zIndex: 1,
     // @ts-ignore Web only -prf
-    left: 'calc(50% - 299px)',
-    width: 598,
+    left: 'calc(50% - 300px)',
+    width: 600,
     top: 0,
     flexDirection: 'row',
     alignItems: 'center',
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
   },
 })
diff --git a/src/view/com/pager/FeedsTabBarMobile.tsx b/src/view/com/pager/FeedsTabBarMobile.tsx
index 9c562f67d..b9959a6d9 100644
--- a/src/view/com/pager/FeedsTabBarMobile.tsx
+++ b/src/view/com/pager/FeedsTabBarMobile.tsx
@@ -142,7 +142,8 @@ export function FeedsTabBar(
 
 const styles = StyleSheet.create({
   tabBar: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     zIndex: 1,
     left: 0,
     right: 0,
diff --git a/src/view/com/pager/Pager.tsx b/src/view/com/pager/Pager.tsx
index 61c3609f2..834b1c0d0 100644
--- a/src/view/com/pager/Pager.tsx
+++ b/src/view/com/pager/Pager.tsx
@@ -17,6 +17,7 @@ export interface PagerRef {
 export interface RenderTabBarFnProps {
   selectedPage: number
   onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element | null | undefined // Ignored on native.
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
diff --git a/src/view/com/pager/Pager.web.tsx b/src/view/com/pager/Pager.web.tsx
index 3b5e9164a..dde799e42 100644
--- a/src/view/com/pager/Pager.web.tsx
+++ b/src/view/com/pager/Pager.web.tsx
@@ -1,10 +1,12 @@
 import React from 'react'
+import {flushSync} from 'react-dom'
 import {View} from 'react-native'
 import {s} from 'lib/styles'
 
 export interface RenderTabBarFnProps {
   selectedPage: number
   onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element
 }
 export type RenderTabBarFn = (props: RenderTabBarFnProps) => JSX.Element
 
@@ -27,6 +29,8 @@ export const Pager = React.forwardRef(function PagerImpl(
   ref,
 ) {
   const [selectedPage, setSelectedPage] = React.useState(initialPage)
+  const scrollYs = React.useRef<Array<number | null>>([])
+  const anchorRef = React.useRef(null)
 
   React.useImperativeHandle(ref, () => ({
     setPage: (index: number) => setSelectedPage(index),
@@ -34,11 +38,36 @@ export const Pager = React.forwardRef(function PagerImpl(
 
   const onTabBarSelect = React.useCallback(
     (index: number) => {
-      setSelectedPage(index)
-      onPageSelected?.(index)
-      onPageSelecting?.(index)
+      const scrollY = window.scrollY
+      // We want to determine if the tabbar is already "sticking" at the top (in which
+      // case we should preserve and restore scroll), or if it is somewhere below in the
+      // viewport (in which case a scroll jump would be jarring). We determine this by
+      // measuring where the "anchor" element is (which we place just above the tabbar).
+      let anchorTop = anchorRef.current
+        ? (anchorRef.current as Element).getBoundingClientRect().top
+        : -scrollY // If there's no anchor, treat the top of the page as one.
+      const isSticking = anchorTop <= 5 // This would be 0 if browser scrollTo() was reliable.
+
+      if (isSticking) {
+        scrollYs.current[selectedPage] = window.scrollY
+      } else {
+        scrollYs.current[selectedPage] = null
+      }
+      flushSync(() => {
+        setSelectedPage(index)
+        onPageSelected?.(index)
+        onPageSelecting?.(index)
+      })
+      if (isSticking) {
+        const restoredScrollY = scrollYs.current[index]
+        if (restoredScrollY != null) {
+          window.scrollTo(0, restoredScrollY)
+        } else {
+          window.scrollTo(0, scrollY + anchorTop)
+        }
+      }
     },
-    [setSelectedPage, onPageSelected, onPageSelecting],
+    [selectedPage, setSelectedPage, onPageSelected, onPageSelecting],
   )
 
   return (
@@ -46,21 +75,11 @@ export const Pager = React.forwardRef(function PagerImpl(
       {tabBarPosition === 'top' &&
         renderTabBar({
           selectedPage,
+          tabBarAnchor: <View ref={anchorRef} />,
           onSelect: onTabBarSelect,
         })}
       {React.Children.map(children, (child, i) => (
-        <View
-          style={
-            selectedPage === i
-              ? s.flex1
-              : {
-                  position: 'absolute',
-                  pointerEvents: 'none',
-                  // @ts-ignore web-only
-                  visibility: 'hidden',
-                }
-          }
-          key={`page-${i}`}>
+        <View style={selectedPage === i ? s.flex1 : s.hidden} key={`page-${i}`}>
           {child}
         </View>
       ))}
diff --git a/src/view/com/pager/PagerWithHeader.tsx b/src/view/com/pager/PagerWithHeader.tsx
index 158940d67..279b607ad 100644
--- a/src/view/com/pager/PagerWithHeader.tsx
+++ b/src/view/com/pager/PagerWithHeader.tsx
@@ -18,7 +18,6 @@ import Animated, {
 } from 'react-native-reanimated'
 import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
 import {TabBar} from './TabBar'
-import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {ListMethods} from '../util/List'
 import {ScrollProvider} from '#/lib/ScrollContext'
@@ -235,7 +234,6 @@ let PagerTabBar = ({
   onCurrentPageSelected?: (index: number) => void
   onSelect?: (index: number) => void
 }): React.ReactNode => {
-  const {isMobile} = useWebMediaQueries()
   const headerTransform = useAnimatedStyle(() => ({
     transform: [
       {
@@ -246,10 +244,7 @@ let PagerTabBar = ({
   return (
     <Animated.View
       pointerEvents="box-none"
-      style={[
-        isMobile ? styles.tabBarMobile : styles.tabBarDesktop,
-        headerTransform,
-      ]}>
+      style={[styles.tabBarMobile, headerTransform]}>
       <View onLayout={onHeaderOnlyLayout} pointerEvents="box-none">
         {renderHeader?.()}
       </View>
@@ -325,14 +320,6 @@ const styles = StyleSheet.create({
     left: 0,
     width: '100%',
   },
-  tabBarDesktop: {
-    position: 'absolute',
-    zIndex: 1,
-    top: 0,
-    // @ts-ignore Web only -prf
-    left: 'calc(50% - 299px)',
-    width: 598,
-  },
 })
 
 function noop() {
diff --git a/src/view/com/pager/PagerWithHeader.web.tsx b/src/view/com/pager/PagerWithHeader.web.tsx
new file mode 100644
index 000000000..0a18a9e7d
--- /dev/null
+++ b/src/view/com/pager/PagerWithHeader.web.tsx
@@ -0,0 +1,194 @@
+import * as React from 'react'
+import {FlatList, ScrollView, StyleSheet, View} from 'react-native'
+import {useAnimatedRef} from 'react-native-reanimated'
+import {Pager, PagerRef, RenderTabBarFnProps} from 'view/com/pager/Pager'
+import {TabBar} from './TabBar'
+import {usePalette} from '#/lib/hooks/usePalette'
+import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {ListMethods} from '../util/List'
+
+export interface PagerWithHeaderChildParams {
+  headerHeight: number
+  isFocused: boolean
+  scrollElRef: React.MutableRefObject<FlatList<any> | ScrollView | null>
+}
+
+export interface PagerWithHeaderProps {
+  testID?: string
+  children:
+    | (((props: PagerWithHeaderChildParams) => JSX.Element) | null)[]
+    | ((props: PagerWithHeaderChildParams) => JSX.Element)
+  items: string[]
+  isHeaderReady: boolean
+  renderHeader?: () => JSX.Element
+  initialPage?: number
+  onPageSelected?: (index: number) => void
+  onCurrentPageSelected?: (index: number) => void
+}
+export const PagerWithHeader = React.forwardRef<PagerRef, PagerWithHeaderProps>(
+  function PageWithHeaderImpl(
+    {
+      children,
+      testID,
+      items,
+      renderHeader,
+      initialPage,
+      onPageSelected,
+      onCurrentPageSelected,
+    }: PagerWithHeaderProps,
+    ref,
+  ) {
+    const [currentPage, setCurrentPage] = React.useState(0)
+
+    const renderTabBar = React.useCallback(
+      (props: RenderTabBarFnProps) => {
+        return (
+          <PagerTabBar
+            items={items}
+            renderHeader={renderHeader}
+            currentPage={currentPage}
+            onCurrentPageSelected={onCurrentPageSelected}
+            onSelect={props.onSelect}
+            tabBarAnchor={props.tabBarAnchor}
+            testID={testID}
+          />
+        )
+      },
+      [items, renderHeader, currentPage, onCurrentPageSelected, testID],
+    )
+
+    const onPageSelectedInner = React.useCallback(
+      (index: number) => {
+        setCurrentPage(index)
+        onPageSelected?.(index)
+      },
+      [onPageSelected, setCurrentPage],
+    )
+
+    const onPageSelecting = React.useCallback((index: number) => {
+      setCurrentPage(index)
+    }, [])
+
+    return (
+      <Pager
+        ref={ref}
+        testID={testID}
+        initialPage={initialPage}
+        onPageSelected={onPageSelectedInner}
+        onPageSelecting={onPageSelecting}
+        renderTabBar={renderTabBar}
+        tabBarPosition="top">
+        {toArray(children)
+          .filter(Boolean)
+          .map((child, i) => {
+            return (
+              <View key={i} collapsable={false}>
+                <PagerItem isFocused={i === currentPage} renderTab={child} />
+              </View>
+            )
+          })}
+      </Pager>
+    )
+  },
+)
+
+let PagerTabBar = ({
+  currentPage,
+  items,
+  testID,
+  renderHeader,
+  onCurrentPageSelected,
+  onSelect,
+  tabBarAnchor,
+}: {
+  currentPage: number
+  items: string[]
+  testID?: string
+  renderHeader?: () => JSX.Element
+  onCurrentPageSelected?: (index: number) => void
+  onSelect?: (index: number) => void
+  tabBarAnchor?: JSX.Element | null | undefined
+}): React.ReactNode => {
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  return (
+    <>
+      <View style={[!isMobile && styles.headerContainerDesktop, pal.border]}>
+        {renderHeader?.()}
+      </View>
+      {tabBarAnchor}
+      <View
+        style={[
+          styles.tabBarContainer,
+          isMobile
+            ? styles.tabBarContainerMobile
+            : styles.tabBarContainerDesktop,
+          pal.border,
+        ]}>
+        <TabBar
+          testID={testID}
+          items={items}
+          selectedPage={currentPage}
+          onSelect={onSelect}
+          onPressSelected={onCurrentPageSelected}
+        />
+      </View>
+    </>
+  )
+}
+PagerTabBar = React.memo(PagerTabBar)
+
+function PagerItem({
+  isFocused,
+  renderTab,
+}: {
+  isFocused: boolean
+  renderTab: ((props: PagerWithHeaderChildParams) => JSX.Element) | null
+}) {
+  const scrollElRef = useAnimatedRef()
+  if (renderTab == null) {
+    return null
+  }
+  return renderTab({
+    headerHeight: 0,
+    isFocused,
+    scrollElRef: scrollElRef as React.MutableRefObject<
+      ListMethods | ScrollView | null
+    >,
+  })
+}
+
+const styles = StyleSheet.create({
+  headerContainerDesktop: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    width: 600,
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  tabBarContainer: {
+    // @ts-ignore web-only
+    position: 'sticky',
+    overflow: 'hidden',
+    top: 0,
+    zIndex: 1,
+  },
+  tabBarContainerDesktop: {
+    marginLeft: 'auto',
+    marginRight: 'auto',
+    width: 600,
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  tabBarContainerMobile: {
+    paddingLeft: 14,
+    paddingRight: 14,
+  },
+})
+
+function toArray<T>(v: T | T[]): T[] {
+  if (Array.isArray(v)) {
+    return v
+  }
+  return [v]
+}
diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx
index cb7fd3f41..49086652f 100644
--- a/src/view/com/post-thread/PostThread.tsx
+++ b/src/view/com/post-thread/PostThread.tsx
@@ -139,7 +139,7 @@ function PostThreadLoaded({
   const {hasSession} = useSession()
   const {_} = useLingui()
   const pal = usePalette('default')
-  const {isTablet, isDesktop} = useWebMediaQueries()
+  const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries()
   const ref = useRef<ListMethods>(null)
   const highlightedPostRef = useRef<View | null>(null)
   const needsScrollAdjustment = useRef<boolean>(
@@ -197,17 +197,35 @@ function PostThreadLoaded({
 
     // wait for loading to finish
     if (thread.type === 'post' && !!thread.parent) {
-      highlightedPostRef.current?.measure(
-        (_x, _y, _width, _height, _pageX, pageY) => {
-          ref.current?.scrollToOffset({
-            animated: false,
-            offset: pageY - (isDesktop ? 0 : 50),
-          })
-        },
-      )
+      function onMeasure(pageY: number) {
+        let spinnerHeight = 0
+        if (isDesktop) {
+          spinnerHeight = 40
+        } else if (isTabletOrMobile) {
+          spinnerHeight = 82
+        }
+        ref.current?.scrollToOffset({
+          animated: false,
+          offset: pageY - spinnerHeight,
+        })
+      }
+      if (isNative) {
+        highlightedPostRef.current?.measure(
+          (_x, _y, _width, _height, _pageX, pageY) => {
+            onMeasure(pageY)
+          },
+        )
+      } else {
+        // Measure synchronously to avoid a layout jump.
+        const domNode = highlightedPostRef.current
+        if (domNode) {
+          const pageY = (domNode as any as Element).getBoundingClientRect().top
+          onMeasure(pageY)
+        }
+      }
       needsScrollAdjustment.current = false
     }
-  }, [thread, isDesktop])
+  }, [thread, isDesktop, isTabletOrMobile])
 
   const onPTR = React.useCallback(async () => {
     setIsPTRing(true)
diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx
index 9abd7d35a..d30a9d805 100644
--- a/src/view/com/util/List.tsx
+++ b/src/view/com/util/List.tsx
@@ -1,4 +1,4 @@
-import React, {memo, startTransition} from 'react'
+import React, {memo} from 'react'
 import {FlatListProps, RefreshControl} from 'react-native'
 import {FlatList_INTERNAL} from './Views'
 import {addStyle} from 'lib/styles'
@@ -39,9 +39,7 @@ function ListImpl<ItemT>(
   const pal = usePalette('default')
 
   function handleScrolledDownChange(didScrollDown: boolean) {
-    startTransition(() => {
-      onScrolledDownChange?.(didScrollDown)
-    })
+    onScrolledDownChange?.(didScrollDown)
   }
 
   const scrollHandler = useAnimatedScrollHandler({
diff --git a/src/view/com/util/List.web.tsx b/src/view/com/util/List.web.tsx
new file mode 100644
index 000000000..3e81a8c37
--- /dev/null
+++ b/src/view/com/util/List.web.tsx
@@ -0,0 +1,341 @@
+import React, {isValidElement, memo, useRef, startTransition} from 'react'
+import {FlatListProps, StyleSheet, View, ViewProps} from 'react-native'
+import {addStyle} from 'lib/styles'
+import {usePalette} from 'lib/hooks/usePalette'
+import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useScrollHandlers} from '#/lib/ScrollContext'
+import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
+import {batchedUpdates} from '#/lib/batchedUpdates'
+
+export type ListMethods = any // TODO: Better types.
+export type ListProps<ItemT> = Omit<
+  FlatListProps<ItemT>,
+  | 'onScroll' // Use ScrollContext instead.
+  | 'refreshControl' // Pass refreshing and/or onRefresh instead.
+  | 'contentOffset' // Pass headerOffset instead.
+> & {
+  onScrolledDownChange?: (isScrolledDown: boolean) => void
+  headerOffset?: number
+  refreshing?: boolean
+  onRefresh?: () => void
+  desktopFixedHeight: any // TODO: Better types.
+}
+export type ListRef = React.MutableRefObject<any | null> // TODO: Better types.
+
+function ListImpl<ItemT>(
+  {
+    ListHeaderComponent,
+    ListFooterComponent,
+    contentContainerStyle,
+    data,
+    desktopFixedHeight,
+    headerOffset,
+    keyExtractor,
+    refreshing: _unsupportedRefreshing,
+    onEndReached,
+    onEndReachedThreshold = 0,
+    onRefresh: _unsupportedOnRefresh,
+    onScrolledDownChange,
+    onContentSizeChange,
+    renderItem,
+    extraData,
+    style,
+    ...props
+  }: ListProps<ItemT>,
+  ref: React.Ref<ListMethods>,
+) {
+  const contextScrollHandlers = useScrollHandlers()
+  const pal = usePalette('default')
+  const {isMobile} = useWebMediaQueries()
+  if (!isMobile) {
+    contentContainerStyle = addStyle(
+      contentContainerStyle,
+      styles.containerScroll,
+    )
+  }
+
+  let header: JSX.Element | null = null
+  if (ListHeaderComponent != null) {
+    if (isValidElement(ListHeaderComponent)) {
+      header = ListHeaderComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      header = <ListHeaderComponent />
+    }
+  }
+
+  let footer: JSX.Element | null = null
+  if (ListFooterComponent != null) {
+    if (isValidElement(ListFooterComponent)) {
+      footer = ListFooterComponent
+    } else {
+      // @ts-ignore Nah it's fine.
+      footer = <ListFooterComponent />
+    }
+  }
+
+  if (headerOffset != null) {
+    style = addStyle(style, {
+      paddingTop: headerOffset,
+    })
+  }
+
+  const nativeRef = React.useRef(null)
+  React.useImperativeHandle(
+    ref,
+    () =>
+      ({
+        scrollToTop() {
+          window.scrollTo({top: 0})
+        },
+        scrollToOffset({
+          animated,
+          offset,
+        }: {
+          animated: boolean
+          offset: number
+        }) {
+          window.scrollTo({
+            left: 0,
+            top: offset,
+            behavior: animated ? 'smooth' : 'instant',
+          })
+        },
+      } as any), // TODO: Better types.
+    [],
+  )
+
+  // --- onContentSizeChange ---
+  const containerRef = useRef(null)
+  useResizeObserver(containerRef, onContentSizeChange)
+
+  // --- onScroll ---
+  const [isInsideVisibleTree, setIsInsideVisibleTree] = React.useState(false)
+  const handleWindowScroll = useNonReactiveCallback(() => {
+    if (isInsideVisibleTree) {
+      contextScrollHandlers.onScroll?.(
+        {
+          contentOffset: {
+            x: Math.max(0, window.scrollX),
+            y: Math.max(0, window.scrollY),
+          },
+        } as any, // TODO: Better types.
+        null as any,
+      )
+    }
+  })
+  React.useEffect(() => {
+    if (!isInsideVisibleTree) {
+      // Prevents hidden tabs from firing scroll events.
+      // Only one list is expected to be firing these at a time.
+      return
+    }
+    window.addEventListener('scroll', handleWindowScroll)
+    return () => {
+      window.removeEventListener('scroll', handleWindowScroll)
+    }
+  }, [isInsideVisibleTree, handleWindowScroll])
+
+  // --- onScrolledDownChange ---
+  const isScrolledDown = useRef(false)
+  function handleAboveTheFoldVisibleChange(isAboveTheFold: boolean) {
+    const didScrollDown = !isAboveTheFold
+    if (isScrolledDown.current !== didScrollDown) {
+      isScrolledDown.current = didScrollDown
+      startTransition(() => {
+        onScrolledDownChange?.(didScrollDown)
+      })
+    }
+  }
+
+  // --- onEndReached ---
+  const onTailVisibilityChange = useNonReactiveCallback(
+    (isTailVisible: boolean) => {
+      if (isTailVisible) {
+        onEndReached?.({
+          distanceFromEnd: onEndReachedThreshold || 0,
+        })
+      }
+    },
+  )
+
+  return (
+    <View {...props} style={style} ref={nativeRef}>
+      <Visibility
+        onVisibleChange={setIsInsideVisibleTree}
+        style={
+          // This has position: fixed, so it should always report as visible
+          // unless we're within a display: none tree (like a hidden tab).
+          styles.parentTreeVisibilityDetector
+        }
+      />
+      <View
+        ref={containerRef}
+        style={[
+          styles.contentContainer,
+          contentContainerStyle,
+          desktopFixedHeight ? styles.minHeightViewport : null,
+          pal.border,
+        ]}>
+        <Visibility
+          onVisibleChange={handleAboveTheFoldVisibleChange}
+          style={[styles.aboveTheFoldDetector, {height: headerOffset}]}
+        />
+        {header}
+        {(data as Array<ItemT>).map((item, index) => (
+          <Row<ItemT>
+            key={keyExtractor!(item, index)}
+            item={item}
+            index={index}
+            renderItem={renderItem}
+            extraData={extraData}
+          />
+        ))}
+        {onEndReached && (
+          <Visibility
+            topMargin={(onEndReachedThreshold ?? 0) * 100 + '%'}
+            onVisibleChange={onTailVisibilityChange}
+          />
+        )}
+        {footer}
+      </View>
+    </View>
+  )
+}
+
+function useResizeObserver(
+  ref: React.RefObject<Element>,
+  onResize: undefined | ((w: number, h: number) => void),
+) {
+  const handleResize = useNonReactiveCallback(onResize ?? (() => {}))
+  const isActive = !!onResize
+  React.useEffect(() => {
+    if (!isActive) {
+      return
+    }
+    const resizeObserver = new ResizeObserver(entries => {
+      batchedUpdates(() => {
+        for (let entry of entries) {
+          const rect = entry.contentRect
+          handleResize(rect.width, rect.height)
+        }
+      })
+    })
+    const node = ref.current!
+    resizeObserver.observe(node)
+    return () => {
+      resizeObserver.unobserve(node)
+    }
+  }, [handleResize, isActive, ref])
+}
+
+let Row = function RowImpl<ItemT>({
+  item,
+  index,
+  renderItem,
+  extraData: _unused,
+}: {
+  item: ItemT
+  index: number
+  renderItem:
+    | null
+    | undefined
+    | ((data: {index: number; item: any; separators: any}) => React.ReactNode)
+  extraData: any
+}): React.ReactNode {
+  if (!renderItem) {
+    return null
+  }
+  return (
+    <View style={styles.row}>
+      {renderItem({item, index, separators: null as any})}
+    </View>
+  )
+}
+Row = React.memo(Row)
+
+let Visibility = ({
+  topMargin = '0px',
+  onVisibleChange,
+  style,
+}: {
+  topMargin?: string
+  onVisibleChange: (isVisible: boolean) => void
+  style?: ViewProps['style']
+}): React.ReactNode => {
+  const tailRef = React.useRef(null)
+  const isIntersecting = React.useRef(false)
+
+  const handleIntersection = useNonReactiveCallback(
+    (entries: IntersectionObserverEntry[]) => {
+      batchedUpdates(() => {
+        entries.forEach(entry => {
+          if (entry.isIntersecting !== isIntersecting.current) {
+            isIntersecting.current = entry.isIntersecting
+            onVisibleChange(entry.isIntersecting)
+          }
+        })
+      })
+    },
+  )
+
+  React.useEffect(() => {
+    const observer = new IntersectionObserver(handleIntersection, {
+      rootMargin: `${topMargin} 0px 0px 0px`,
+    })
+    const tail: Element | null = tailRef.current!
+    observer.observe(tail)
+    return () => {
+      observer.unobserve(tail)
+    }
+  }, [handleIntersection, topMargin])
+
+  return (
+    <View ref={tailRef} style={addStyle(styles.visibilityDetector, style)} />
+  )
+}
+Visibility = React.memo(Visibility)
+
+export const List = memo(React.forwardRef(ListImpl)) as <ItemT>(
+  props: ListProps<ItemT> & {ref?: React.Ref<ListMethods>},
+) => React.ReactElement
+
+const styles = StyleSheet.create({
+  contentContainer: {
+    borderLeftWidth: 1,
+    borderRightWidth: 1,
+  },
+  containerScroll: {
+    width: '100%',
+    maxWidth: 600,
+    marginLeft: 'auto',
+    marginRight: 'auto',
+  },
+  row: {
+    // @ts-ignore web only
+    contentVisibility: 'auto',
+  },
+  minHeightViewport: {
+    // @ts-ignore web only
+    minHeight: '100vh',
+  },
+  parentTreeVisibilityDetector: {
+    // @ts-ignore web only
+    position: 'fixed',
+    top: 0,
+    left: 0,
+    right: 0,
+    bottom: 0,
+  },
+  aboveTheFoldDetector: {
+    position: 'absolute',
+    top: 0,
+    left: 0,
+    right: 0,
+    // Bottom is dynamic.
+  },
+  visibilityDetector: {
+    pointerEvents: 'none',
+    zIndex: -1,
+  },
+})
diff --git a/src/view/com/util/MainScrollProvider.tsx b/src/view/com/util/MainScrollProvider.tsx
index 3ac28d31f..2c90e33ff 100644
--- a/src/view/com/util/MainScrollProvider.tsx
+++ b/src/view/com/util/MainScrollProvider.tsx
@@ -1,9 +1,10 @@
-import React, {useCallback} from 'react'
+import React, {useCallback, useEffect} from 'react'
+import EventEmitter from 'eventemitter3'
 import {ScrollProvider} from '#/lib/ScrollContext'
 import {NativeScrollEvent} from 'react-native'
 import {useSetMinimalShellMode, useMinimalShellMode} from '#/state/shell'
 import {useShellLayout} from '#/state/shell/shell-layout'
-import {isNative} from 'platform/detection'
+import {isNative, isWeb} from 'platform/detection'
 import {useSharedValue, interpolate} from 'react-native-reanimated'
 
 const WEB_HIDE_SHELL_THRESHOLD = 200
@@ -20,6 +21,15 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
   const startDragOffset = useSharedValue<number | null>(null)
   const startMode = useSharedValue<number | null>(null)
 
+  useEffect(() => {
+    if (isWeb) {
+      return listenToForcedWindowScroll(() => {
+        startDragOffset.value = null
+        startMode.value = null
+      })
+    }
+  })
+
   const onBeginDrag = useCallback(
     (e: NativeScrollEvent) => {
       'worklet'
@@ -100,3 +110,26 @@ export function MainScrollProvider({children}: {children: React.ReactNode}) {
     </ScrollProvider>
   )
 }
+
+const emitter = new EventEmitter()
+
+if (isWeb) {
+  const originalScroll = window.scroll
+  window.scroll = function () {
+    emitter.emit('forced-scroll')
+    return originalScroll.apply(this, arguments as any)
+  }
+
+  const originalScrollTo = window.scrollTo
+  window.scrollTo = function () {
+    emitter.emit('forced-scroll')
+    return originalScrollTo.apply(this, arguments as any)
+  }
+}
+
+function listenToForcedWindowScroll(listener: () => void) {
+  emitter.addListener('forced-scroll', listener)
+  return () => {
+    emitter.removeListener('forced-scroll', listener)
+  }
+}
diff --git a/src/view/com/util/SimpleViewHeader.tsx b/src/view/com/util/SimpleViewHeader.tsx
index e86e37565..814b2fb15 100644
--- a/src/view/com/util/SimpleViewHeader.tsx
+++ b/src/view/com/util/SimpleViewHeader.tsx
@@ -14,6 +14,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useAnalytics} from 'lib/analytics/analytics'
 import {NavigationProp} from 'lib/routes/types'
 import {useSetDrawerOpen} from '#/state/shell'
+import {isWeb} from '#/platform/detection'
 
 const BACK_HITSLOP = {left: 20, top: 20, right: 50, bottom: 20}
 
@@ -47,7 +48,14 @@ export function SimpleViewHeader({
 
   const Container = isMobile ? View : CenteredView
   return (
-    <Container style={[styles.header, isMobile && styles.headerMobile, style]}>
+    <Container
+      style={[
+        styles.header,
+        isMobile && styles.headerMobile,
+        isWeb && styles.headerWeb,
+        pal.view,
+        style,
+      ]}>
       {showBackButton ? (
         <TouchableOpacity
           testID="viewHeaderDrawerBtn"
@@ -89,6 +97,12 @@ const styles = StyleSheet.create({
     paddingHorizontal: 12,
     paddingVertical: 10,
   },
+  headerWeb: {
+    // @ts-ignore web-only
+    position: 'sticky',
+    top: 0,
+    zIndex: 1,
+  },
   backBtn: {
     width: 30,
     height: 30,
diff --git a/src/view/com/util/Toast.web.tsx b/src/view/com/util/Toast.web.tsx
index beb67c30c..d5a843541 100644
--- a/src/view/com/util/Toast.web.tsx
+++ b/src/view/com/util/Toast.web.tsx
@@ -64,7 +64,8 @@ export function show(text: string, icon: FontAwesomeProps['icon'] = 'check') {
 
 const styles = StyleSheet.create({
   container: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     left: 20,
     bottom: 20,
     // @ts-ignore web only
diff --git a/src/view/com/util/fab/FABInner.tsx b/src/view/com/util/fab/FABInner.tsx
index 9787d92fb..27a16117b 100644
--- a/src/view/com/util/fab/FABInner.tsx
+++ b/src/view/com/util/fab/FABInner.tsx
@@ -6,6 +6,7 @@ import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
 import {useSafeAreaInsets} from 'react-native-safe-area-context'
 import {clamp} from 'lib/numbers'
 import {useMinimalShellMode} from 'lib/hooks/useMinimalShellMode'
+import {isWeb} from '#/platform/detection'
 import Animated from 'react-native-reanimated'
 
 export interface FABProps
@@ -64,7 +65,8 @@ const styles = StyleSheet.create({
     borderRadius: 35,
   },
   outer: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     zIndex: 1,
   },
   inner: {
diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx
index aaadbf399..276dc842c 100644
--- a/src/view/screens/PostThread.tsx
+++ b/src/view/screens/PostThread.tsx
@@ -25,6 +25,7 @@ import {ErrorMessage} from '../com/util/error/ErrorMessage'
 import {CenteredView} from '../com/util/Views'
 import {useComposerControls} from '#/state/shell/composer'
 import {useSession} from '#/state/session'
+import {isWeb} from '#/platform/detection'
 
 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostThread'>
 export function PostThreadScreen({route}: Props) {
@@ -112,7 +113,8 @@ export function PostThreadScreen({route}: Props) {
 
 const styles = StyleSheet.create({
   prompt: {
-    position: 'absolute',
+    // @ts-ignore web-only
+    position: isWeb ? 'fixed' : 'absolute',
     left: 0,
     right: 0,
   },
diff --git a/src/view/screens/Search/Search.tsx b/src/view/screens/Search/Search.tsx
index 94aab2d96..bfa8e1b28 100644
--- a/src/view/screens/Search/Search.tsx
+++ b/src/view/screens/Search/Search.tsx
@@ -334,7 +334,9 @@ export function SearchScreenInner({
         tabBarPosition="top"
         onPageSelected={onPageSelected}
         renderTabBar={props => (
-          <CenteredView sideBorders style={pal.border}>
+          <CenteredView
+            sideBorders
+            style={[pal.border, pal.view, styles.tabBarContainer]}>
             <TabBar items={SECTIONS_LOGGEDIN} {...props} />
           </CenteredView>
         )}
@@ -375,7 +377,9 @@ export function SearchScreenInner({
       tabBarPosition="top"
       onPageSelected={onPageSelected}
       renderTabBar={props => (
-        <CenteredView sideBorders style={pal.border}>
+        <CenteredView
+          sideBorders
+          style={[pal.border, pal.view, styles.tabBarContainer]}>
           <TabBar items={SECTIONS_LOGGEDOUT} {...props} />
         </CenteredView>
       )}
@@ -466,6 +470,7 @@ export function SearchScreen(
     setDrawerOpen(true)
   }, [track, setDrawerOpen])
   const onPressCancelSearch = React.useCallback(() => {
+    scrollToTopWeb()
     textInput.current?.blur()
     setQuery('')
     setShowAutocompleteResults(false)
@@ -473,11 +478,13 @@ export function SearchScreen(
       clearTimeout(searchDebounceTimeout.current)
   }, [textInput])
   const onPressClearQuery = React.useCallback(() => {
+    scrollToTopWeb()
     setQuery('')
     setShowAutocompleteResults(false)
   }, [setQuery])
   const onChangeText = React.useCallback(
     async (text: string) => {
+      scrollToTopWeb()
       setQuery(text)
 
       if (text.length > 0) {
@@ -506,10 +513,12 @@ export function SearchScreen(
     [setQuery, search, setSearchResults],
   )
   const onSubmit = React.useCallback(() => {
+    scrollToTopWeb()
     setShowAutocompleteResults(false)
   }, [setShowAutocompleteResults])
 
   const onSoftReset = React.useCallback(() => {
+    scrollToTopWeb()
     onPressCancelSearch()
   }, [onPressCancelSearch])
 
@@ -526,11 +535,12 @@ export function SearchScreen(
   )
 
   return (
-    <View style={{flex: 1}}>
+    <View style={isWeb ? null : {flex: 1}}>
       <CenteredView
         style={[
           styles.header,
           pal.border,
+          pal.view,
           isTabletOrDesktop && {paddingTop: 10},
         ]}
         sideBorders={isTabletOrDesktop}>
@@ -661,12 +671,25 @@ export function SearchScreen(
   )
 }
 
+function scrollToTopWeb() {
+  if (isWeb) {
+    window.scrollTo(0, 0)
+  }
+}
+
+const HEADER_HEIGHT = 50
+
 const styles = StyleSheet.create({
   header: {
     flexDirection: 'row',
     alignItems: 'center',
     paddingHorizontal: 12,
     paddingVertical: 4,
+    height: HEADER_HEIGHT,
+    // @ts-ignore web only
+    position: isWeb ? 'sticky' : '',
+    top: 0,
+    zIndex: 1,
   },
   headerMenuBtn: {
     width: 30,
@@ -696,4 +719,10 @@ const styles = StyleSheet.create({
   headerCancelBtn: {
     paddingLeft: 10,
   },
+  tabBarContainer: {
+    // @ts-ignore web only
+    position: isWeb ? 'sticky' : '',
+    top: isWeb ? HEADER_HEIGHT : 0,
+    zIndex: 1,
+  },
 })
diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx
index ed64bc799..99e659d62 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -5,6 +5,7 @@ import {ComposePost} from '../com/composer/Composer'
 import {useComposerState} from 'state/shell/composer'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {
   EmojiPicker,
   EmojiPickerState,
@@ -16,6 +17,8 @@ export function Composer({}: {winHeight: number}) {
   const pal = usePalette('default')
   const {isMobile} = useWebMediaQueries()
   const state = useComposerState()
+  const isActive = !!state
+  useWebBodyScrollLock(isActive)
 
   const [pickerState, setPickerState] = React.useState<EmojiPickerState>({
     isOpen: false,
@@ -40,7 +43,7 @@ export function Composer({}: {winHeight: number}) {
   // rendering
   // =
 
-  if (!state) {
+  if (!isActive) {
     return <View />
   }
 
@@ -75,7 +78,8 @@ export function Composer({}: {winHeight: number}) {
 
 const styles = StyleSheet.create({
   mask: {
-    position: 'absolute',
+    // @ts-ignore
+    position: 'fixed',
     top: 0,
     left: 0,
     width: '100%',
diff --git a/src/view/shell/bottom-bar/BottomBarStyles.tsx b/src/view/shell/bottom-bar/BottomBarStyles.tsx
index ae9381440..f226406f5 100644
--- a/src/view/shell/bottom-bar/BottomBarStyles.tsx
+++ b/src/view/shell/bottom-bar/BottomBarStyles.tsx
@@ -12,6 +12,10 @@ export const styles = StyleSheet.create({
     paddingLeft: 5,
     paddingRight: 10,
   },
+  bottomBarWeb: {
+    // @ts-ignore web-only
+    position: 'fixed',
+  },
   ctrl: {
     flex: 1,
     paddingTop: 13,
diff --git a/src/view/shell/bottom-bar/BottomBarWeb.tsx b/src/view/shell/bottom-bar/BottomBarWeb.tsx
index c5dc376b7..b330c4b80 100644
--- a/src/view/shell/bottom-bar/BottomBarWeb.tsx
+++ b/src/view/shell/bottom-bar/BottomBarWeb.tsx
@@ -57,6 +57,7 @@ export function BottomBarWeb() {
     <Animated.View
       style={[
         styles.bottomBar,
+        styles.bottomBarWeb,
         pal.view,
         pal.border,
         {paddingBottom: clamp(safeAreaInsets.bottom, 15, 30)},
diff --git a/src/view/shell/desktop/LeftNav.tsx b/src/view/shell/desktop/LeftNav.tsx
index c84e86b95..b27898828 100644
--- a/src/view/shell/desktop/LeftNav.tsx
+++ b/src/view/shell/desktop/LeftNav.tsx
@@ -442,10 +442,11 @@ export function DesktopLeftNav() {
 
 const styles = StyleSheet.create({
   leftNav: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     top: 10,
     // @ts-ignore web only
-    right: 'calc(50vw + 312px)',
+    left: 'calc(50vw - 300px - 220px - 20px)',
     width: 220,
     // @ts-ignore web only
     maxHeight: 'calc(100vh - 10px)',
diff --git a/src/view/shell/desktop/RightNav.tsx b/src/view/shell/desktop/RightNav.tsx
index 842991d6f..328c527e4 100644
--- a/src/view/shell/desktop/RightNav.tsx
+++ b/src/view/shell/desktop/RightNav.tsx
@@ -177,9 +177,10 @@ function InviteCodes() {
 
 const styles = StyleSheet.create({
   rightNav: {
-    position: 'absolute',
     // @ts-ignore web only
-    left: 'calc(50vw + 320px)',
+    position: 'fixed',
+    // @ts-ignore web only
+    left: 'calc(50vw + 300px + 20px)',
     width: 300,
     maxHeight: '100%',
     overflowY: 'auto',
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 1ada883c9..76f4f5c9b 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -15,6 +15,7 @@ import {useAuxClick} from 'lib/hooks/useAuxClick'
 import {t} from '@lingui/macro'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useCloseAllActiveElements} from '#/state/util'
+import {useWebBodyScrollLock} from '#/lib/hooks/useWebBodyScrollLock'
 import {Outlet as PortalOutlet} from '#/components/Portal'
 
 function ShellInner() {
@@ -24,6 +25,7 @@ function ShellInner() {
   const navigator = useNavigation<NavigationProp>()
   const closeAllActiveElements = useCloseAllActiveElements()
 
+  useWebBodyScrollLock(isDrawerOpen)
   useAuxClick()
 
   useEffect(() => {
@@ -34,12 +36,10 @@ function ShellInner() {
   }, [navigator, closeAllActiveElements])
 
   return (
-    <View style={[s.hContentRegion, {overflow: 'hidden'}]}>
-      <View style={s.hContentRegion}>
-        <ErrorBoundary>
-          <FlatNavigator />
-        </ErrorBoundary>
-      </View>
+    <>
+      <ErrorBoundary>
+        <FlatNavigator />
+      </ErrorBoundary>
       <Composer winHeight={0} />
       <ModalsContainer />
       <PortalOutlet />
@@ -55,7 +55,7 @@ function ShellInner() {
           </View>
         </TouchableOpacity>
       )}
-    </View>
+    </>
   )
 }
 
@@ -78,7 +78,8 @@ const styles = StyleSheet.create({
     backgroundColor: colors.black, // TODO
   },
   drawerMask: {
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     width: '100%',
     height: '100%',
     top: 0,
@@ -87,7 +88,8 @@ const styles = StyleSheet.create({
   },
   drawerContainer: {
     display: 'flex',
-    position: 'absolute',
+    // @ts-ignore web only
+    position: 'fixed',
     top: 0,
     left: 0,
     height: '100%',
diff --git a/web/index.html b/web/index.html
index a82abea90..92001e716 100644
--- a/web/index.html
+++ b/web/index.html
@@ -37,10 +37,10 @@
       }
 
       html {
-        scroll-behavior: smooth;
         /* Prevent text size change on orientation change https://gist.github.com/tfausak/2222823#file-ios-8-web-app-html-L138 */
         -webkit-text-size-adjust: 100%;
         height: calc(100% + env(safe-area-inset-top));
+        scrollbar-gutter: stable;
       }
 
       /* Remove autofill styles on Webkit */
diff --git a/yarn.lock b/yarn.lock
index 3a7756939..3e6ae4cf9 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7525,6 +7525,13 @@
   dependencies:
     "@types/react" "*"
 
+"@types/react-dom@^18.2.18":
+  version "18.2.18"
+  resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.2.18.tgz#16946e6cd43971256d874bc3d0a72074bb8571dd"
+  integrity sha512-TJxDm6OfAX2KJWJdMEVTwWke5Sc/E/RlnPGvGfS0W7+6ocy2xhDVQVh/KvC2Uf7kACs+gDytdusDSdWfWkaNzw==
+  dependencies:
+    "@types/react" "*"
+
 "@types/react-responsive@^8.0.5":
   version "8.0.5"
   resolved "https://registry.yarnpkg.com/@types/react-responsive/-/react-responsive-8.0.5.tgz#77769862d2a0711434feb972be08e3e6c334440a"