diff --git a/app/assets/javascripts/discourse/app/components/discourse-topic.js b/app/assets/javascripts/discourse/app/components/discourse-topic.js
index 2e14817089e..126ad57a20f 100644
--- a/app/assets/javascripts/discourse/app/components/discourse-topic.js
+++ b/app/assets/javascripts/discourse/app/components/discourse-topic.js
@@ -1,21 +1,15 @@
 import { getOwner } from "@ember/application";
 import Component from "@ember/component";
 import { alias } from "@ember/object/computed";
-import { schedule, scheduleOnce, throttle } from "@ember/runloop";
-import { service } from "@ember/service";
+import { schedule, scheduleOnce } from "@ember/runloop";
 import { isBlank } from "@ember/utils";
 import $ from "jquery";
 import ClickTrack from "discourse/lib/click-track";
-import DiscourseURL from "discourse/lib/url";
 import { highlightPost } from "discourse/lib/utilities";
-import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
 import Scrolling from "discourse/mixins/scrolling";
-import discourseLater from "discourse-common/lib/later";
 import { bind, observes } from "discourse-common/utils/decorators";
 
-const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 300;
-
-export default Component.extend(Scrolling, MobileScrollDirection, {
+export default Component.extend(Scrolling, {
   userFilters: alias("topic.userFilters"),
   classNameBindings: [
     "multiSelect",
@@ -24,25 +18,18 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
     "topic.category.read_restricted:read_restricted",
     "topic.deleted:deleted-topic",
   ],
-  header: service(),
   menuVisible: true,
   SHORT_POST: 1200,
 
   postStream: alias("topic.postStream"),
   dockAt: 0,
 
-  _lastShowTopic: null,
-
-  mobileScrollDirection: null,
-  pauseHeaderTopicUpdate: false,
-
   @observes("enteredAt")
   _enteredTopic() {
     // Ember is supposed to only call observers when values change but something
     // in our view set up is firing this observer with the same value. This check
     // prevents scrolled from being called twice
     if (this.enteredAt && this.lastEnteredAt !== this.enteredAt) {
-      this._lastShowTopic = null;
       schedule("afterRender", this.scrolled);
       this.set("lastEnteredAt", this.enteredAt);
     }
@@ -54,53 +41,10 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
     }
   },
 
-  _hideTopicInHeader() {
-    this.appEvents.trigger("header:hide-topic");
-    this.header.topicInfoVisible = false;
-    this._lastShowTopic = false;
-  },
-
-  _showTopicInHeader(topic) {
-    if (this.pauseHeaderTopicUpdate) {
-      return;
-    }
-    this.appEvents.trigger("header:show-topic", topic);
-    this.header.topicInfoVisible = true;
-    this._lastShowTopic = true;
-  },
-
-  _updateTopic(topic, debounceDuration) {
-    if (topic === null) {
-      this._hideTopicInHeader();
-
-      if (debounceDuration && !this.pauseHeaderTopicUpdate) {
-        this.pauseHeaderTopicUpdate = true;
-        this._lastShowTopic = true;
-
-        discourseLater(() => {
-          this._lastShowTopic = false;
-          this.pauseHeaderTopicUpdate = false;
-        }, debounceDuration);
-      }
-
-      return;
-    }
-
-    const offset = window.pageYOffset || document.documentElement.scrollTop;
-    this._lastShowTopic = this.shouldShowTopicInHeader(topic, offset);
-
-    if (this._lastShowTopic) {
-      this._showTopicInHeader(topic);
-    } else {
-      this._hideTopicInHeader();
-    }
-  },
-
   init() {
     this._super(...arguments);
     this.appEvents.on("discourse:focus-changed", this, "gotFocus");
     this.appEvents.on("post:highlight", this, "_highlightPost");
-    this.appEvents.on("header:update-topic", this, "_updateTopic");
   },
 
   didInsertElement() {
@@ -119,10 +63,8 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
     this._super(...arguments);
 
     // this happens after route exit, stuff could have trickled in
-    this._hideTopicInHeader();
     this.appEvents.off("discourse:focus-changed", this, "gotFocus");
     this.appEvents.off("post:highlight", this, "_highlightPost");
-    this.appEvents.off("header:update-topic", this, "_updateTopic");
   },
 
   willDestroyElement() {
@@ -133,8 +75,6 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
 
     // Unbind link tracking
     $(this.element).off("click.discourse-redirect", ".cooked a, a.track-link");
-
-    this.resetExamineDockCache();
   },
 
   gotFocus(hasFocus) {
@@ -143,20 +83,6 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
     }
   },
 
-  resetExamineDockCache() {
-    this.set("dockAt", 0);
-  },
-
-  shouldShowTopicInHeader(topic, offset) {
-    // On mobile, we show the header topic if the user has scrolled past the topic
-    // title and the current scroll direction is down
-    // On desktop the user only needs to scroll past the topic title.
-    return (
-      offset > this.dockAt &&
-      (this.site.desktopView || this.mobileScrollDirection === "down")
-    );
-  },
-
   // The user has scrolled the window, or it is finished rendering and ready for processing.
   @bind
   scrolled() {
@@ -165,54 +91,9 @@ export default Component.extend(Scrolling, MobileScrollDirection, {
     }
 
     const offset = window.pageYOffset || document.documentElement.scrollTop;
-    if (this.dockAt === 0) {
-      const title = document.querySelector("#topic-title");
-      if (title) {
-        this.set("dockAt", title.getBoundingClientRect().top + window.scrollY);
-      }
-    }
-
     this.set("hasScrolled", offset > 0);
 
-    const showTopic = this.shouldShowTopicInHeader(this.topic, offset);
-
-    if (showTopic !== this._lastShowTopic) {
-      if (showTopic) {
-        this._showTopicInHeader(this.topic);
-      } else {
-        if (!DiscourseURL.isJumpScheduled()) {
-          const loadingNear = this.topic.get("postStream.loadingNearPost") || 1;
-          if (loadingNear === 1) {
-            this._hideTopicInHeader();
-          }
-        }
-      }
-    }
-
-    // Since the user has scrolled, we need to check the scroll direction on mobile.
-    // We use throttle instead of debounce because we want the switch to occur
-    // at the start of the scroll. This feels a lot more snappy compared to waiting
-    // for the scroll to end if we debounce.
-    if (this.site.mobileView && this.hasScrolled) {
-      throttle(
-        this,
-        this.calculateDirection,
-        offset,
-        MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
-      );
-    }
-
     // Trigger a scrolled event
     this.appEvents.trigger("topic:scrolled", offset);
   },
-
-  // We observe the scroll direction on mobile and if it's down, we show the topic
-  // in the header, otherwise, we hide it.
-  @observes("mobileScrollDirection")
-  toggleMobileHeaderTopic() {
-    return this.appEvents.trigger(
-      "header:update-topic",
-      this.mobileScrollDirection === "down" ? this.topic : null
-    );
-  },
 });
diff --git a/app/assets/javascripts/discourse/app/components/footer-nav.js b/app/assets/javascripts/discourse/app/components/footer-nav.js
index 1464e5c4443..7cf400d4fa8 100644
--- a/app/assets/javascripts/discourse/app/components/footer-nav.js
+++ b/app/assets/javascripts/discourse/app/components/footer-nav.js
@@ -1,168 +1,144 @@
-import { throttle } from "@ember/runloop";
+import { service } from "@ember/service";
 import MountWidget from "discourse/components/mount-widget";
 import { postRNWebviewMessage } from "discourse/lib/utilities";
-import MobileScrollDirection from "discourse/mixins/mobile-scroll-direction";
-import Scrolling from "discourse/mixins/scrolling";
-import { observes } from "discourse-common/utils/decorators";
+import { SCROLLED_UP, UNSCROLLED } from "discourse/services/scroll-direction";
+import { bind, observes } from "discourse-common/utils/decorators";
 
-const MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE = 150;
+const FooterNavComponent = MountWidget.extend({
+  widget: "footer-nav",
+  classNames: ["footer-nav", "visible"],
+  scrollDirection: service(),
+  routeHistory: [],
+  currentRouteIndex: 0,
+  canGoBack: false,
+  canGoForward: false,
+  backForwardClicked: null,
 
-const FooterNavComponent = MountWidget.extend(
-  Scrolling,
-  MobileScrollDirection,
-  {
-    widget: "footer-nav",
-    mobileScrollDirection: null,
-    scrollEventDisabled: false,
-    classNames: ["footer-nav", "visible"],
-    routeHistory: [],
-    currentRouteIndex: 0,
-    canGoBack: false,
-    canGoForward: false,
-    backForwardClicked: null,
+  buildArgs() {
+    return {
+      canGoBack: this.canGoBack,
+      canGoForward: this.canGoForward,
+    };
+  },
 
-    buildArgs() {
-      return {
-        canGoBack: this.canGoBack,
-        canGoForward: this.canGoForward,
-      };
-    },
+  didInsertElement() {
+    this._super(...arguments);
+    this.appEvents.on("page:changed", this, "_routeChanged");
 
-    didInsertElement() {
-      this._super(...arguments);
-      this.appEvents.on("page:changed", this, "_routeChanged");
+    if (this.capabilities.isAppWebview) {
+      this.appEvents.on("modal:body-shown", this, "_modalOn");
+      this.appEvents.on("modal:body-dismissed", this, "_modalOff");
+    }
 
-      if (this.capabilities.isAppWebview) {
-        this.appEvents.on("modal:body-shown", this, "_modalOn");
-        this.appEvents.on("modal:body-dismissed", this, "_modalOff");
-      }
+    if (this.capabilities.isIpadOS) {
+      document.documentElement.classList.add("footer-nav-ipad");
+    } else {
+      this.appEvents.on("composer:opened", this, "_composerOpened");
+      this.appEvents.on("composer:closed", this, "_composerClosed");
+      document.documentElement.classList.add("footer-nav-visible");
+    }
 
-      if (this.capabilities.isIpadOS) {
-        document.documentElement.classList.add("footer-nav-ipad");
-      } else {
-        this.bindScrolling();
-        window.addEventListener("resize", this.scrolled, false);
-        this.appEvents.on("composer:opened", this, "_composerOpened");
-        this.appEvents.on("composer:closed", this, "_composerClosed");
-        document.documentElement.classList.add("footer-nav-visible");
-      }
-    },
+    this.scrollDirection.addObserver(
+      "lastScrollDirection",
+      this.toggleMobileFooter
+    );
+  },
 
-    willDestroyElement() {
-      this._super(...arguments);
-      this.appEvents.off("page:changed", this, "_routeChanged");
+  willDestroyElement() {
+    this._super(...arguments);
+    this.appEvents.off("page:changed", this, "_routeChanged");
 
-      if (this.capabilities.isAppWebview) {
-        this.appEvents.off("modal:body-shown", this, "_modalOn");
-        this.appEvents.off("modal:body-removed", this, "_modalOff");
-      }
+    if (this.capabilities.isAppWebview) {
+      this.appEvents.off("modal:body-shown", this, "_modalOn");
+      this.appEvents.off("modal:body-removed", this, "_modalOff");
+    }
 
-      if (this.capabilities.isIpadOS) {
-        document.documentElement.classList.remove("footer-nav-ipad");
-      } else {
-        this.unbindScrolling();
-        window.removeEventListener("resize", this.scrolled);
-        this.appEvents.off("composer:opened", this, "_composerOpened");
-        this.appEvents.off("composer:closed", this, "_composerClosed");
-      }
-    },
+    if (this.capabilities.isIpadOS) {
+      document.documentElement.classList.remove("footer-nav-ipad");
+    } else {
+      this.unbindScrolling();
+      window.removeEventListener("resize", this.scrolled);
+      this.appEvents.off("composer:opened", this, "_composerOpened");
+      this.appEvents.off("composer:closed", this, "_composerClosed");
+    }
 
-    // The user has scrolled the window, or it is finished rendering and ready for processing.
-    scrolled() {
-      if (
-        this.isDestroyed ||
-        this.isDestroying ||
-        this._state !== "inDOM" ||
-        this.scrollEventDisabled
-      ) {
-        return;
-      }
+    this.scrollDirection.removeObserver(
+      "lastScrollDirection",
+      this.toggleMobileFooter
+    );
+  },
 
-      throttle(
-        this,
-        this.calculateDirection,
-        window.pageYOffset,
-        MOBILE_SCROLL_DIRECTION_CHECK_THROTTLE
+  @bind
+  toggleMobileFooter() {
+    const visible = [UNSCROLLED, SCROLLED_UP].includes(
+      this.scrollDirection.lastScrollDirection
+    );
+    this.element.classList.toggle("visible", visible);
+    document.documentElement.classList.toggle("footer-nav-visible", visible);
+  },
+
+  _routeChanged(route) {
+    // only update route history if not using back/forward nav
+    if (this.backForwardClicked) {
+      this.backForwardClicked = null;
+      return;
+    }
+
+    this.routeHistory.push(route.url);
+    this.set("currentRouteIndex", this.routeHistory.length);
+
+    this.queueRerender();
+  },
+
+  _composerOpened() {
+    this.set("mobileScrollDirection", "down");
+    this.set("scrollEventDisabled", true);
+  },
+
+  _composerClosed() {
+    this.set("mobileScrollDirection", null);
+    this.set("scrollEventDisabled", false);
+  },
+
+  _modalOn() {
+    const backdrop = document.querySelector(".modal-backdrop");
+    if (backdrop) {
+      postRNWebviewMessage(
+        "headerBg",
+        getComputedStyle(backdrop)["background-color"]
       );
-    },
+    }
+  },
 
-    // We observe the scroll direction on mobile and if it's down, we show the topic
-    // in the header, otherwise, we hide it.
-    @observes("mobileScrollDirection")
-    toggleMobileFooter() {
-      this.element.classList.toggle(
-        "visible",
-        this.mobileScrollDirection === null ? true : false
+  _modalOff() {
+    const dheader = document.querySelector(".d-header");
+    if (dheader) {
+      postRNWebviewMessage(
+        "headerBg",
+        getComputedStyle(dheader)["background-color"]
       );
-      document.documentElement.classList.toggle(
-        "footer-nav-visible",
-        this.mobileScrollDirection === null ? true : false
-      );
-    },
+    }
+  },
 
-    _routeChanged(route) {
-      // only update route history if not using back/forward nav
-      if (this.backForwardClicked) {
-        this.backForwardClicked = null;
-        return;
-      }
+  goBack() {
+    this.set("currentRouteIndex", this.currentRouteIndex - 1);
+    this.backForwardClicked = true;
+    window.history.back();
+  },
 
-      this.routeHistory.push(route.url);
-      this.set("currentRouteIndex", this.routeHistory.length);
+  goForward() {
+    this.set("currentRouteIndex", this.currentRouteIndex + 1);
+    this.backForwardClicked = true;
+    window.history.forward();
+  },
 
-      this.queueRerender();
-    },
+  @observes("currentRouteIndex")
+  setBackForward() {
+    let index = this.currentRouteIndex;
 
-    _composerOpened() {
-      this.set("mobileScrollDirection", "down");
-      this.set("scrollEventDisabled", true);
-    },
-
-    _composerClosed() {
-      this.set("mobileScrollDirection", null);
-      this.set("scrollEventDisabled", false);
-    },
-
-    _modalOn() {
-      const backdrop = document.querySelector(".modal-backdrop");
-      if (backdrop) {
-        postRNWebviewMessage(
-          "headerBg",
-          getComputedStyle(backdrop)["background-color"]
-        );
-      }
-    },
-
-    _modalOff() {
-      const dheader = document.querySelector(".d-header");
-      if (dheader) {
-        postRNWebviewMessage(
-          "headerBg",
-          getComputedStyle(dheader)["background-color"]
-        );
-      }
-    },
-
-    goBack() {
-      this.set("currentRouteIndex", this.currentRouteIndex - 1);
-      this.backForwardClicked = true;
-      window.history.back();
-    },
-
-    goForward() {
-      this.set("currentRouteIndex", this.currentRouteIndex + 1);
-      this.backForwardClicked = true;
-      window.history.forward();
-    },
-
-    @observes("currentRouteIndex")
-    setBackForward() {
-      let index = this.currentRouteIndex;
-
-      this.set("canGoBack", index > 1 || document.referrer ? true : false);
-      this.set("canGoForward", index < this.routeHistory.length ? true : false);
-    },
-  }
-);
+    this.set("canGoBack", index > 1 || document.referrer ? true : false);
+    this.set("canGoForward", index < this.routeHistory.length ? true : false);
+  },
+});
 
 export default FooterNavComponent;
diff --git a/app/assets/javascripts/discourse/app/components/site-header.js b/app/assets/javascripts/discourse/app/components/site-header.js
index e1d58087af7..aa2016c311a 100644
--- a/app/assets/javascripts/discourse/app/components/site-header.js
+++ b/app/assets/javascripts/discourse/app/components/site-header.js
@@ -1,4 +1,5 @@
 import { DEBUG } from "@glimmer/env";
+import { getOwner } from "@ember/owner";
 import { schedule } from "@ember/runloop";
 import { waitForPromise } from "@ember/test-waiters";
 import ItsATrap from "@discourse/itsatrap";
@@ -212,9 +213,14 @@ const SiteHeaderComponent = MountWidget.extend(
       }
     },
 
-    setTopic(topic) {
+    setTopic() {
+      const header = getOwner(this).lookup("service:header");
+      if (header.topicInfoVisible) {
+        this._topic = header.topicInfo;
+      } else {
+        this._topic = null;
+      }
       this.eventDispatched("dom:clean", "header");
-      this._topic = topic;
       this.queueRerender();
     },
 
@@ -231,8 +237,9 @@ const SiteHeaderComponent = MountWidget.extend(
       this._resizeDiscourseMenuPanel = () => this.afterRender();
       window.addEventListener("resize", this._resizeDiscourseMenuPanel);
 
-      this.appEvents.on("header:show-topic", this, "setTopic");
-      this.appEvents.on("header:hide-topic", this, "setTopic");
+      const headerService = getOwner(this).lookup("service:header");
+      headerService.addObserver("topicInfoVisible", this, "setTopic");
+      headerService.topicInfoVisible; // Access property to set up observer
 
       this.appEvents.on("user-menu:rendered", this, "_animateMenu");
 
@@ -299,9 +306,9 @@ const SiteHeaderComponent = MountWidget.extend(
       this._super(...arguments);
 
       window.removeEventListener("resize", this._resizeDiscourseMenuPanel);
-
-      this.appEvents.off("header:show-topic", this, "setTopic");
-      this.appEvents.off("header:hide-topic", this, "setTopic");
+      getOwner(this)
+        .lookup("service:header")
+        .removeObserver("topicInfoVisible", this, "setTopic");
       this.appEvents.off("dom:clean", this, "_cleanDom");
       this.appEvents.off("user-menu:rendered", this, "_animateMenu");
 
diff --git a/app/assets/javascripts/discourse/app/components/topic-title.gjs b/app/assets/javascripts/discourse/app/components/topic-title.gjs
index 3576fe987e4..0f629d28f6f 100644
--- a/app/assets/javascripts/discourse/app/components/topic-title.gjs
+++ b/app/assets/javascripts/discourse/app/components/topic-title.gjs
@@ -3,8 +3,10 @@ import { hash } from "@ember/helper";
 import { on } from "@ember/modifier";
 import { action } from "@ember/object";
 import didInsert from "@ember/render-modifiers/modifiers/did-insert";
+import { service } from "@ember/service";
 import PluginOutlet from "discourse/components/plugin-outlet";
 import { isiPad } from "discourse/lib/utilities";
+import observeIntersection from "discourse/modifiers/observe-intersection";
 
 export let topicTitleDecorators = [];
 
@@ -17,6 +19,8 @@ export function resetTopicTitleDecorators() {
 }
 
 export default class TopicTitle extends Component {
+  @service header;
+
   @action
   applyDecorators(element) {
     const fancyTitle = element.querySelector(".fancy-title");
@@ -54,6 +58,7 @@ export default class TopicTitle extends Component {
     <div
       {{didInsert this.applyDecorators}}
       {{on "keydown" this.keyDown}}
+      {{observeIntersection this.header.titleIntersectionChanged}}
       id="topic-title"
       class="container"
     >
diff --git a/app/assets/javascripts/discourse/app/mixins/mobile-scroll-direction.js b/app/assets/javascripts/discourse/app/mixins/mobile-scroll-direction.js
deleted file mode 100644
index 76cf825edb6..00000000000
--- a/app/assets/javascripts/discourse/app/mixins/mobile-scroll-direction.js
+++ /dev/null
@@ -1,62 +0,0 @@
-import Mixin from "@ember/object/mixin";
-import $ from "jquery";
-import discourseDebounce from "discourse-common/lib/debounce";
-
-// Small buffer so that very tiny scrolls don't trigger mobile header switch
-const MOBILE_SCROLL_TOLERANCE = 5;
-
-export default Mixin.create({
-  _lastScroll: null,
-  _bottomHit: 0,
-
-  calculateDirection(offset) {
-    // Difference between this scroll and the one before it.
-    const delta = Math.floor(offset - this._lastScroll);
-
-    // This is a tiny scroll, so we ignore it.
-    if (delta <= MOBILE_SCROLL_TOLERANCE && delta >= -MOBILE_SCROLL_TOLERANCE) {
-      return;
-    }
-
-    // don't calculate when resetting offset (i.e. going to /latest or to next topic in suggested list)
-    if (offset === 0) {
-      return;
-    }
-
-    const prevDirection = this.mobileScrollDirection;
-    const currDirection = delta > 0 ? "down" : null;
-
-    const distanceToBottom = Math.floor(
-      $("body").height() - offset - $(window).height()
-    );
-
-    // Handle Safari top overscroll first
-    if (offset < 0) {
-      this.set("mobileScrollDirection", null);
-    } else if (currDirection !== prevDirection && distanceToBottom > 0) {
-      this.set("mobileScrollDirection", currDirection);
-    }
-
-    // We store this to compare against it the next time the user scrolls
-    this._lastScroll = Math.floor(offset);
-
-    // Not at the bottom yet
-    if (distanceToBottom > 0) {
-      this._bottomHit = 0;
-      return;
-    }
-
-    // If the user reaches the very bottom of the topic, we only want to reset
-    // this scroll direction after a second scroll down. This is a nicer event
-    // similar to what Safari and Chrome do.
-    discourseDebounce(this, this._setBottomHit, 1000);
-
-    if (this._bottomHit === 1) {
-      this.set("mobileScrollDirection", null);
-    }
-  },
-
-  _setBottomHit() {
-    this._bottomHit = 1;
-  },
-});
diff --git a/app/assets/javascripts/discourse/app/modifiers/observe-intersection.js b/app/assets/javascripts/discourse/app/modifiers/observe-intersection.js
new file mode 100644
index 00000000000..719f86c92a3
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/modifiers/observe-intersection.js
@@ -0,0 +1,13 @@
+import { modifier } from "ember-modifier";
+
+export default modifier((element, [callback], { threshold = 1 }) => {
+  const observer = new IntersectionObserver((entries) => {
+    entries.forEach(callback, { threshold });
+  });
+
+  observer.observe(element);
+
+  return () => {
+    observer.disconnect();
+  };
+});
diff --git a/app/assets/javascripts/discourse/app/routes/topic-from-params.js b/app/assets/javascripts/discourse/app/routes/topic-from-params.js
index 242ebb7354c..76521095bd9 100644
--- a/app/assets/javascripts/discourse/app/routes/topic-from-params.js
+++ b/app/assets/javascripts/discourse/app/routes/topic-from-params.js
@@ -39,19 +39,20 @@ export default class TopicFromParams extends DiscourseRoute {
       });
   }
 
-  afterModel() {
+  afterModel(model) {
     const topic = this.modelFor("topic");
 
     if (topic.isPrivateMessage && topic.suggested_topics) {
       this.pmTopicTrackingState.startTracking();
     }
 
-    this.header.topicInfo = topic;
+    this.header.enterTopic(topic, model.nearPost);
   }
 
   deactivate() {
     super.deactivate(...arguments);
     this.controllerFor("topic").unsubscribe();
+    this.header.clearTopic();
   }
 
   setupController(controller, params, { _discourse_anchor }) {
diff --git a/app/assets/javascripts/discourse/app/services/header.js b/app/assets/javascripts/discourse/app/services/header.js
index 1212aae523f..38ec17a9704 100644
--- a/app/assets/javascripts/discourse/app/services/header.js
+++ b/app/assets/javascripts/discourse/app/services/header.js
@@ -1,15 +1,20 @@
 import { tracked } from "@glimmer/tracking";
 import { registerDestructor } from "@ember/destroyable";
+import { action } from "@ember/object";
+import { dependentKeyCompat } from "@ember/object/compat";
 import Service, { service } from "@ember/service";
 import { TrackedMap } from "@ember-compat/tracked-built-ins";
 import { disableImplicitInjections } from "discourse/lib/implicit-injections";
 import deprecated from "discourse-common/lib/deprecated";
+import { SCROLLED_UP } from "./scroll-direction";
 
 const VALID_HEADER_BUTTONS_TO_HIDE = ["search", "login", "signup"];
 
 @disableImplicitInjections
 export default class Header extends Service {
   @service siteSettings;
+  @service scrollDirection;
+  @service site;
 
   /**
    * The topic currently viewed on the page.
@@ -20,14 +25,7 @@ export default class Header extends Service {
    */
   @tracked topicInfo = null;
 
-  /**
-   * Indicates whether the topic information is visible on the header.
-   *
-   * The information is updated when the user scrolls the page.
-   *
-   * @type {boolean}
-   */
-  @tracked topicInfoVisible = false;
+  @tracked mainTopicTitleVisible = false;
 
   @tracked hamburgerVisible = false;
   @tracked userVisible = false;
@@ -48,6 +46,33 @@ export default class Header extends Service {
     return this.topicInfoVisible ? this.topicInfo : null;
   }
 
+  /**
+   * Indicates whether topic info should be displayed
+   * in the header.
+   */
+  @dependentKeyCompat // For legacy `site-header` observer compat
+  get topicInfoVisible() {
+    if (!this.topicInfo) {
+      // Not on a topic page
+      return false;
+    }
+
+    if (this.mainTopicTitleVisible) {
+      // Title is already visible on screen, no need to duplicate
+      return false;
+    }
+
+    if (
+      this.site.mobileView &&
+      this.scrollDirection.lastScrollDirection === SCROLLED_UP
+    ) {
+      // On mobile, we hide the topic info when scrolling up
+      return false;
+    }
+
+    return true;
+  }
+
   get useGlimmerHeader() {
     if (this.siteSettings.glimmer_header_mode === "disabled") {
       return false;
@@ -103,4 +128,35 @@ export default class Header extends Service {
     });
     return Array.from(buttonsToHide);
   }
+
+  /**
+   * Called by a modifier attached to the main topic title element.
+   */
+  @action
+  titleIntersectionChanged(e) {
+    if (e.isIntersecting) {
+      this.mainTopicTitleVisible = true;
+    } else if (e.boundingClientRect.top > 0) {
+      // Title is below the curent viewport position. Unusual, but can be caused with
+      // small viewport and/or large headers. Treat same as if title is on screen.
+      this.mainTopicTitleVisible = true;
+    } else {
+      this.mainTopicTitleVisible = false;
+    }
+  }
+
+  /**
+   * Called whenever a topic route is entered. Sets the current topicInfo,
+   * and makes a guess about whether the main topic title is likely to be visible
+   * on initial load. The IntersectionObserver will correct this later if needed.
+   */
+  enterTopic(topic, postNumber) {
+    this.topicInfo = topic;
+    this.mainTopicTitleVisible = !postNumber || postNumber === 1;
+  }
+
+  clearTopic() {
+    this.topicInfo = null;
+    this.mainTopicTitleVisible = false;
+  }
 }
diff --git a/app/assets/javascripts/discourse/app/services/scroll-direction.js b/app/assets/javascripts/discourse/app/services/scroll-direction.js
new file mode 100644
index 00000000000..451ec07dcac
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/services/scroll-direction.js
@@ -0,0 +1,137 @@
+import { tracked } from "@glimmer/tracking";
+import { next, throttle } from "@ember/runloop";
+import Service, { service } from "@ember/service";
+import { disableImplicitInjections } from "discourse/lib/implicit-injections";
+import discourseDebounce from "discourse-common/lib/debounce";
+import { bind } from "discourse-common/utils/decorators";
+
+// Small buffer so that very tiny scrolls don't trigger mobile header switch
+const MOBILE_SCROLL_TOLERANCE = 5;
+
+const PAUSE_AFTER_TRANSITION_MS = 1000;
+
+export const UNSCROLLED = Symbol("unscrolled"),
+  SCROLLED_DOWN = Symbol("scroll-down"),
+  SCROLLED_UP = Symbol("scroll-up");
+
+@disableImplicitInjections
+export default class ScrollDirection extends Service {
+  @service router;
+  @tracked lastScrollDirection = UNSCROLLED;
+
+  #lastScroll = null;
+  #bottomHit = 0;
+  #paused = false;
+
+  constructor() {
+    super(...arguments);
+    this.routeDidChange();
+    window.addEventListener("scroll", this.onScroll, { passive: true });
+    this.router.on("routeWillChange", this.routeWillChange);
+    this.router.on("routeDidChange", this.routeDidChange);
+  }
+
+  willDestroy() {
+    window.removeEventListener("scroll", this.onScroll);
+    this.router.off("routeDidChange", this.routeDidChange);
+  }
+
+  @bind
+  routeWillChange() {
+    // Pause detection until the transition is over
+    this.#paused = true;
+  }
+
+  @bind
+  routeDidChange() {
+    this.#paused = true;
+
+    // User hasn't scrolled yet on this route
+    this.lastScrollDirection = UNSCROLLED;
+
+    // Wait for the initial DOM render to be done
+    next(() => {
+      // Then allow a bit of extra time for any DOM shifts to settle
+      discourseDebounce(this.unpause, PAUSE_AFTER_TRANSITION_MS);
+    });
+  }
+
+  @bind
+  unpause() {
+    this.#paused = false;
+  }
+
+  @bind
+  onScroll() {
+    if (this.#paused) {
+      this.#lastScroll = window.scrollY;
+      return;
+    } else {
+      throttle(this.handleScroll, 100, false);
+    }
+  }
+
+  @bind
+  handleScroll() {
+    // Unfortunately no public API for this
+    // eslint-disable-next-line ember/no-private-routing-service
+    if (this.router._router._routerMicrolib.activeTransition) {
+      // console.log("activetransition");
+      return;
+    }
+
+    const offset = window.scrollY;
+    this.calculateDirection(offset);
+  }
+
+  calculateDirection(offset) {
+    // Difference between this scroll and the one before it.
+    const delta = Math.floor(offset - this.#lastScroll);
+
+    // This is a tiny scroll, so we ignore it.
+    if (delta <= MOBILE_SCROLL_TOLERANCE && delta >= -MOBILE_SCROLL_TOLERANCE) {
+      return;
+    }
+
+    // don't calculate when resetting offset (i.e. going to /latest or to next topic in suggested list)
+    if (offset === 0) {
+      return;
+    }
+
+    const prevDirection = this.lastScrollDirection;
+    const currDirection = delta > 0 ? SCROLLED_DOWN : SCROLLED_UP;
+
+    const distanceToBottom = Math.floor(
+      document.body.clientHeight - offset - window.innerHeight
+    );
+
+    // Handle Safari top overscroll first
+    if (offset < 0) {
+      this.lastScrollDirection = UNSCROLLED;
+    } else if (currDirection !== prevDirection && distanceToBottom > 0) {
+      this.lastScrollDirection = currDirection;
+    }
+
+    // We store this to compare against it the next time the user scrolls
+    this.#lastScroll = Math.floor(offset);
+
+    if (distanceToBottom > 0) {
+      this.#bottomHit = 0;
+    } else {
+      // If the user reaches the very bottom of the topic, we only want to reset
+      // this scroll direction after a second scroll down. This is a nicer event
+      // similar to what Safari and Chrome do.
+      discourseDebounce(this, this.#setBottomHit, 1000);
+
+      if (this.#bottomHit === 1) {
+        this.lastScrollDirection = UNSCROLLED;
+      }
+    }
+
+    this.lastScrollTimestamp = Date.now();
+  }
+
+  #setBottomHit() {
+    this.#bottomHit = 1;
+  }
+}
diff --git a/spec/system/header_spec.rb b/spec/system/header_spec.rb
index 0697d2448fa..b04a2c84bea 100644
--- a/spec/system/header_spec.rb
+++ b/spec/system/header_spec.rb
@@ -259,4 +259,32 @@ RSpec.describe "Glimmer Header", type: :system do
       expect(search).to have_no_search_menu_visible
     end
   end
+
+  describe "mobile topic-info" do
+    fab!(:topic)
+    fab!(:posts) { Fabricate.times(5, :post, topic: topic) }
+
+    it "only shows when scrolled down", mobile: true do
+      visit "/t/#{topic.slug}/#{topic.id}"
+
+      expect(page).to have_css("#topic-title") # Main topic title
+      expect(page).to have_css("header.d-header .auth-buttons .login-button") # header buttons visible when no topic-info in header
+
+      page.execute_script("document.querySelector('#post_4').scrollIntoView()")
+
+      expect(page).not_to have_css("header.d-header .auth-buttons .login-button") # No header buttons
+      expect(page).to have_css("header.d-header .title-wrapper .topic-link") # Title is shown in header
+
+      page.execute_script("window.scrollTo(0, 0)")
+      expect(page).to have_css("#topic-title") # Main topic title
+      expect(page).to have_css("header.d-header .auth-buttons .login-button") # header buttons visible when no topic-info in header
+    end
+
+    it "shows when navigating direct to a later post", mobile: true do
+      visit "/t/#{topic.slug}/#{topic.id}/4"
+
+      expect(page).not_to have_css("header.d-header .auth-buttons .login-button") # No header buttons
+      expect(page).to have_css("header.d-header .title-wrapper .topic-link") # Title is shown in header
+    end
+  end
 end